Receiving webhooks
A plugin can receive calls from a service outside Timon (GitHub, Stripe, Slack, a chat bot, your own relay) without anyone being signed in. Timon checks every call in the service's own signature format before your code sees it. Your plugin never verifies a signature itself.
Declare and subscribe
"timon": {
"id": "acme-tickets",
"apiVersion": 8,
"permissions": ["webhooks:receive", "todos:read", "todos:write"],
"webhooks": [{ "name": "issues", "auth": "github" }]
}
export function apply(ctx) {
ctx.webhooks.on('issues', async (req) => {
if (req.headers['x-github-event'] !== 'issues') return;
const { action, issue } = req.json();
const todo = await ctx.kanban.todos.links.findByRef('github', `acme/app#${issue.number}`);
if (todo && action === 'closed') await ctx.kanban.todos.close(todo.id);
});
}
nameis a lowercase slug, unique within the plugin.authnames the signature format of the service that calls you: one of the formats below. It defaults tostandard. Any value other thanstandardneeds"apiVersion": 8or later; an older Timon refuses such a manifest and says which version it needs.ctx.webhooks.onaccepts declared names only, one handler per name. It stops when your plugin is disabled.- The handler receives
name,id,timestamp,headers,textandjson():idis the delivery id: the signed id when the format has one, otherwise the service's delivery header (for exampleX-GitHub-Delivery), otherwisesha256:followed by the fingerprint of the body. It is the same when the service retries.timestampis the signed timestamp in milliseconds, or the time Timon received the call when the format signs none.headersare lowercase, without the signature header of the format and without any credential.textis the exact body;json()parses it. Configure the service to send JSON (application/json): a form-encoded body makesjson()throw.
Address and secret
The address is https://<your Timon address>/api/plugins/hooks/<slug>/<name>, where <slug> is your plugin id without @ and with / replaced by - (@acme/tickets → acme-tickets). An administrator finds it under Settings › Plugins › your plugin › Incoming webhooks, with the format, its security level and the secret controls. Declare this address at the service.
Where the secret comes from depends on the format:
- Generated by Timon. Click Generate secret, copy the value shown once, paste it at the service. GitHub, Gitea, Forgejo, GitLab and Standard Webhooks work this way.
- Provided by the service. The service shows its own secret (or public key). Click Paste the service's secret (or Paste the public key) and paste it in Timon. Stripe, Slack, Shopify, Linear, Discord and Meta work this way, and so can Standard Webhooks.
Only an administrator signed in to the app can generate or paste a secret; an agent or a command-line token cannot. A pasted value is checked against the format (length, shape, for example whsec_ for Stripe), stored encrypted, and never shown again.
Formats
Each format is a description shipped and tested with Timon. A plugin chooses one; it cannot bring its own.
auth | Service | Signature | Secret | Level |
|---|---|---|---|---|
standard | Standard Webhooks, another Timon plugin, GitLab signing token | HMAC-SHA256 of id, timestamp and body | generated or provided | strong |
github | GitHub | HMAC-SHA256 of the body | generated | signed |
gitea | Gitea | HMAC-SHA256 of the body | generated | signed |
forgejo | Forgejo | HMAC-SHA256 of the body | generated | signed |
gitlab | GitLab (secret token) | none: shared token | generated | weak |
stripe | Stripe | HMAC-SHA256 of timestamp and body | provided | strong |
slack | Slack | HMAC-SHA256 of timestamp and body | provided | strong |
shopify | Shopify | HMAC-SHA256 of the body | provided | signed |
linear | Linear | HMAC-SHA256 of the body, timestamp inside it | provided | strong |
discord | Discord interactions | Ed25519 of timestamp and body | provided (public key) | strong |
meta | Meta (Facebook, Instagram, WhatsApp) | HMAC-SHA256 of the body | provided | signed-body |
Security levels
- strong — the signature covers a timestamp, and Timon refuses a call outside its window (5 minutes, 1 minute for Linear). A captured call cannot be replayed later.
- signed — the body is signed and the service sends a delivery id. A replayed call is recognised as already handled.
- signed-body — the body is signed but the service sends no delivery id: replays are recognised by the body's fingerprint, so two identical bodies count as one delivery.
- weak — a shared token in a header, not a signature. Anyone who sees one call (a proxy log, a misconfigured mirror) holds the token and can forge any body. Prefer a signed format when the service offers one: GitLab 19.1 and later can sign with a signing token, use
standardfor it.
Three kinds of check cover every format: an HMAC over a signed string that always contains the body (hmac), a public-key signature (ed25519), and the shared-token fallback (token). There is no format without a check.
standard
- Secret: generated by Timon (
whsec_…), or provided by a service that makes its own Standard Webhooks secret. - Headers:
webhook-id,webhook-timestamp,webhook-signature: v1,<base64>(several signatures separated by spaces are accepted). - Delivery id:
webhook-id, signed. - A call made by
ctx.outbox.post(…, { sign: { secret } })from another Timon plugin uses this format.
github
- Secret: generated by Timon. On GitHub: repository or organisation Settings › Webhooks › Add webhook, field Secret.
- At GitHub: Payload URL = the address, Content type =
application/json, then choose the events. - Header:
X-Hub-Signature-256: sha256=<hex>. The olderX-Hub-Signature(SHA-1) is not used. - Delivery id:
X-GitHub-Delivery. GitHub's first call is aping(X-GitHub-Event: ping): ignore it in your handler.
gitea and forgejo
- Secret: generated by Timon. In the repository's Settings › Webhooks › Add webhook › Gitea (or Forgejo), field Secret; POST content type =
application/json. - Header:
X-Gitea-SignatureorX-Forgejo-Signature, the hex HMAC without prefix. - Delivery id:
X-Gitea-DeliveryorX-Forgejo-Delivery.
gitlab
- Secret: generated by Timon. In the project's Settings › Webhooks › Add new webhook, field Secret token.
- Header:
X-Gitlab-Token, compared in constant time. Level weak, see above. - Delivery id:
Idempotency-Key(GitLab 17.4 and later), otherwise the body's fingerprint.
stripe
- Secret: provided by Stripe. In Workbench › Webhooks, open the endpoint and reveal its signing secret (
whsec_…); paste it in Timon. The endpoint URL is the address. - Header:
Stripe-Signature: t=<timestamp>,v1=<hex>. Severalv1are accepted while you roll the secret at Stripe;v0is ignored. - Window: 5 minutes.
- Delivery id: none in a header, so the body's fingerprint: a retry of the same event is recognised even though Stripe signs it again with a new timestamp. The event id is also in the body (
id).
slack
- Secret: provided by Slack. In your app's Basic Information › App Credentials › Signing Secret; paste it in Timon. Declare the address as the Request URL of Event Subscriptions or of a slash command.
- Headers:
X-Slack-Signature: v0=<hex>,X-Slack-Request-Timestamp. Window: 5 minutes. - Handshake: Slack's
url_verificationcall is answered by Timon itself, after checking its signature, by echoing itschallenge. Your handler never sees it. - Delivery id: the body's fingerprint. A slash command is form-encoded: read
req.text, notreq.json().
shopify
- Secret: provided by Shopify: your app's client secret. Paste it in Timon; declare the address as the subscription's HTTPS endpoint.
- Header:
X-Shopify-Hmac-SHA256, base64. - Delivery id:
X-Shopify-Webhook-Id.
linear
- Secret: provided by Linear, on the webhook's detail page (Settings › API › Webhooks). Paste it in Timon.
- Header:
Linear-Signature, hex HMAC of the body. - Window: 1 minute, read from
webhookTimestampin the signed body (theLinear-Timestampheader is not signed, so it is not used). - Delivery id:
Linear-Delivery.
discord
- Public key: provided by Discord, in the Developer Portal: your application's General Information › Public Key (64 hexadecimal characters). Paste it in Timon with Paste the public key. Declare the address as the Interactions Endpoint URL.
- Headers:
X-Signature-Ed25519,X-Signature-Timestamp. Window: 5 minutes. Discord's security checks send invalid signatures on purpose: they get401, as Discord expects. - Handshake: Discord's
PING({"type":1}) is answered{"type":1}by Timon itself, after checking its signature. - Delivery id: the body's fingerprint (the interaction id is in the body).
- Reply: an interaction expects its response in the reply, within 3 seconds; see Replying.
meta
- Secret: provided by Meta: the app's App Secret (App settings › Basic). Paste it in Timon: Timon then shows a verify token, once. In the app's Webhooks product, enter the address as Callback URL and this verify token as Verify Token.
- Handshake: Meta first calls the address with a
GETcarryinghub.mode=subscribe,hub.verify_tokenandhub.challenge. Timon answers the challenge only when the verify token is exactly the one it showed; your plugin is not called. The verify token changes with the secret: paste a new App Secret, enter the new verify token at Meta. - Header:
X-Hub-Signature-256: sha256=<hex>. - Delivery id: none, so the body's fingerprint. Level signed-body.
Not supported: services that sign the URL (Twilio), that require fetching a remote certificate (Amazon SNS, PayPal), or that send a JWT (Atlassian Connect). Use a relay that re-signs with standard, or a trigger.
Replying
- A handler that returns nothing answers
200 {"status":"received"}. - A handler that returns a value answers
200with that value as the JSON body (API 8). This is how a Discord interaction gets its message, or a Slack command its text. Timon setsContent-Type: application/jsonitself, and the serialized value is limited to 64 KiB; a larger or non-serializable value is treated as a failure. - A handler that throws, or takes longer than 10 seconds, answers
502and the service retries the same delivery: make your handler safe to run twice with the samereq.id. - Discord waits only 3 seconds for an interaction reply, far less than Timon's 10. For slow work, return a deferred response (
{ "type": 5 }) at once, then send the real message to Discord's follow-up endpoint withctx.outbox.post.
Response codes
| Code | When |
|---|---|
200 {"status":"received"} or your value | The handler resolved |
200 {"status":"duplicate"} | This delivery was already handled; the handler is not called again |
200 with the handshake reply | Slack url_verification, Discord PING, Meta's GET |
401 {"error":"authentication failed"} | Any refusal: unknown plugin or name, disabled plugin, no secret yet, a secret of another format, missing, wrong or expired signature, wrong verify token. Always the same body, whatever the reason |
409 {"status":"in_progress"} + Retry-After | The same delivery arrived while its first call is still running |
413 | Body larger than 256 KiB |
429 + Retry-After | Too many calls from one address or to one webhook |
502 {"status":"error"} | The handler threw, timed out or returned an invalid reply; the service should retry |
Rotating or revoking the secret
- Generating or pasting a new secret keeps the previous one valid for 24 hours, so the service can be switched without losing calls. Revoke it now ends that period at once; do it when the old secret may have leaked.
- Changing the format is a new secret. When a plugin update changes a webhook's
auth(for example fromgithubtogitea), the stored secret stops working at once: the webhook answers401and the settings pane asks for a new secret. The new one replaces the old immediately, with no 24-hour overlap.
What your handler can rely on
- Only correctly signed, recent calls reach it. Any refused call gets the same answer.
- A delivery it has already handled is answered without calling it again.
- It runs as the administrator who generated or pasted the secret (
ctx.user()), seeing that account's data, or as your plugin's own background identity when there is no account yet. Writes to TO DO ideas carryorigin: { kind: 'plugin', id: ctx.id }, so your own event handlers can skip them.