Hooks
A URL per recipient that turns a plain text, Discord, Slack or Apprise request into a notification.
A hook is a secret URL that belongs to one recipient. Anything that can call a webhook can send that recipient a notification through it: no API key, no JSON to build and no code to write. Monitoring tools, CI pipelines and shell scripts are the usual callers. Ready-made setups are in Recipes for tools and Shell one-liners.
⫻
plain text
What a hook is#
- A hook is owned by exactly one recipient. It can only notify that recipient, never another one in the project.
- Its token is
hk_followed by 32 letters and digits, and it sits in the URL path. The same token works with four request shapes, picked by the end of the path:
⫻
hook URLs
- A hook request is a normal send: it goes to the recipient's browsers as a browser notification, with the same validation, delivery policy, billing and statuses as
POST /v1/notifications. - Every hook request is live. There is no test mode for hooks, so each send creates real attempts and counts toward usage like a live API key.
NoteThe Discord, Slack and Apprise shapes are only how a tool talks to Push. Push does not post anything to Discord or Slack: every hook request becomes a notification in the recipient's browsers.
Creating, rotating and revoking#
- Create a hook on the recipient's page in the dashboard and give it a name, such as the tool that will call it.
- A recipient can have up to 10 active hooks. Revoked hooks do not count.
- The full URL is shown once, when the hook is created. Push stores only a SHA-256 hash of the token and keeps its first 12 characters so you can tell hooks apart.
- Rotate gives the hook a new token and shows the new URL once. The old URL stops working at once; the hook keeps its name and history. Rotating a revoked hook brings it back with the new token.
- Revoke stops the hook on the next request. Callers then get
401 UNAUTHORIZED, or the shape's own error. - Each hook shows when it was last used.
The four shapes#
| Path | Body | Use it for |
|---|---|---|
| /v1/hooks/{token} | Plain text, with ntfy-style headers | curl, shell scripts, cron jobs, CI steps |
| /v1/hooks/{token}/discord | A Discord webhook message | Any tool with a Discord webhook option |
| /v1/hooks/{token}/slack | A Slack incoming-webhook message | Any tool with a Slack webhook URL option |
| /v1/hooks/{token}/apprise | Apprise's JSON notification | Apprise and tools built on it |
Each shape is turned into a title, a body, an optional link and a priority by the rules below, then sent like any other notification.
Plain text#
- The request body is read as plain text, whatever its
Content-Type. Title(alsoX-Titleort) sets the title, and the whole body becomes the notification body.- Without a title header, the first non-empty line of the body is the title and the lines after it are the body.
Tags(alsoX-Tagsorta) is a comma-separated list. Up to 10 tags are kept and passed to the notification asdata.tags; they count toward the 1,024-byte limit ofdata.Click(alsoX-Click) becomes the notification'surl. It must be anhttpsURL, or the request fails with400 VALIDATION_ERROR.Priority(alsoX-Priorityorp) takes ntfy's values, in any letter case:
| Priority header | Push priority |
|---|---|
| 1, min, 2, low | low |
| 3, default | high (the default) |
| 4, high | high |
| 5, max, urgent | urgent |
Any other value is ignored and the notification goes out with the default priority, high.
Discord#
⫻
Discord
- The body is Discord's webhook JSON.
multipart/form-datais accepted too, with the JSON in apayload_jsonfield or plaincontentandusernamefields. Attached files are ignored. - Only the first embed is read.
- Title: the embed's
title, else itsauthor.name, elseusername, else the first line ofcontent. - Body:
content(without its first line when that line became the title), then the embed'sdescription, onename: valueline per field, and the footer text. - Link: the embed's
url. Large image: the embed'simage.url. Icon:avatar_url. Each is used only when it is anhttpsURL and is dropped silently otherwise. - Discord markdown is turned into plain text: bold, italic, strikethrough, spoilers, inline code and code fences are unwrapped,
[label](url)becomes its label, quotes lose their>and mentions are removed.<t:…>timestamps becomeYYYY-MM-DD HH:mm UTC. - A message with no
contentand no embed fails with Discord's error50006,Cannot send an empty message.
Slack#
⫻
Slack
- The body is Slack's incoming-webhook JSON. A form post with the JSON in a
payloadfield is accepted too. - Title: the text of the first
headerblock, else the first attachment'stitle, else the first line oftext. - Body:
text(without its first line when that line became the title), then the text and fields of everysectionblock, then the first attachment'spretext,textand onetitle: valueline per field. - Link: the first attachment's
title_link, else the first<https://…>link found intext, the section texts or the attachment text. Onlyhttpslinks are used. - Slack's mrkdwn is turned into plain text:
<url|label>becomes its label, bare<url>becomes the URL, mentions are removed, and bold, italic, strikethrough and inline code markers are removed. - A message with no
text, no blocks and no attachment fails with Slack'sno_text.
Apprise#
⫻
Apprise
- The body is the JSON that Apprise's
json://andjsons://services send. Other fields, such asversionandattachments, are ignored. - Title:
title. Without it, the first line ofbody(ormessage) is the title and the rest is the body. - Body:
body, ormessagewhen there is nobody. typesets the priority:infoandsuccessbecomenormal,warningbecomeshighandfailurebecomesurgent. Withouttypethe priority ishigh. Any othertypevalue fails with400 VALIDATION_ERROR.
Rules for every shape#
- An empty title becomes
Notification. - The title is cut to 120 characters and the body to 480, each ending with
…when it was cut. The cut text then goes through the normal field rules and size limits. - Without a priority from the shape, the notification is sent with priority
high, as an API send would be. - Hooks cannot schedule or set a TTL: every hook notification is sent at once with the default TTL of 86,400 seconds. Use the API for scheduling.
- The request body may be up to 1 MiB, so a tool's large payload is read whole; only the extracted notification has to fit the normal limits.
- An
Idempotency-Keyheader (1–255 characters) works exactly as on the API: the same key and the same extracted notification replay the first result, and a different notification gets409 IDEMPOTENCY_CONFLICT. See Idempotency. - A recipient with no connected browsers gets
422 INVALID_RECIPIENT, and a send the credits cannot cover gets402 QUOTA_EXCEEDED, in the shape's own error format. See Hook endpoints.
Rate limits#
- Each hook takes up to 60 requests per minute.
- Hook requests also count toward the project's 300 and the account's 600 requests per minute, shared with API keys. See Rate limits.
- Requests with an unknown or revoked token count toward the per-IP limit of 120 invalid requests per minute.
- Over a limit, the answer is
429with aRetry-Afterheader in seconds.
Keep the URL secret#
WarningA hook URL is a credential. Unlike a live API key, it is accepted from any origin, browsers included, because the tools that call hooks run everywhere. Anyone who has the URL can notify the recipient and spend your credits, so keep it in a secret store and rotate it if it leaks.
NoteA hook is tied to one recipient on purpose: a leaked URL can only reach that recipient's browsers, and revoking it does not affect anything else in the project.