Skip to main content

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);
});
}
  • name is a lowercase slug, unique within the plugin.
  • auth names the signature format of the service that calls you: one of the formats below. It defaults to standard. Any value other than standard needs "apiVersion": 8 or later; an older Timon refuses such a manifest and says which version it needs.
  • ctx.webhooks.on accepts declared names only, one handler per name. It stops when your plugin is disabled.
  • The handler receives name, id, timestamp, headers, text and json():
    • id is the delivery id: the signed id when the format has one, otherwise the service's delivery header (for example X-GitHub-Delivery), otherwise sha256: followed by the fingerprint of the body. It is the same when the service retries.
    • timestamp is the signed timestamp in milliseconds, or the time Timon received the call when the format signs none.
    • headers are lowercase, without the signature header of the format and without any credential.
    • text is the exact body; json() parses it. Configure the service to send JSON (application/json): a form-encoded body makes json() 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.

authServiceSignatureSecretLevel
standardStandard Webhooks, another Timon plugin, GitLab signing tokenHMAC-SHA256 of id, timestamp and bodygenerated or providedstrong
githubGitHubHMAC-SHA256 of the bodygeneratedsigned
giteaGiteaHMAC-SHA256 of the bodygeneratedsigned
forgejoForgejoHMAC-SHA256 of the bodygeneratedsigned
gitlabGitLab (secret token)none: shared tokengeneratedweak
stripeStripeHMAC-SHA256 of timestamp and bodyprovidedstrong
slackSlackHMAC-SHA256 of timestamp and bodyprovidedstrong
shopifyShopifyHMAC-SHA256 of the bodyprovidedsigned
linearLinearHMAC-SHA256 of the body, timestamp inside itprovidedstrong
discordDiscord interactionsEd25519 of timestamp and bodyprovided (public key)strong
metaMeta (Facebook, Instagram, WhatsApp)HMAC-SHA256 of the bodyprovidedsigned-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 standard for 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 older X-Hub-Signature (SHA-1) is not used.
  • Delivery id: X-GitHub-Delivery. GitHub's first call is a ping (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-Signature or X-Forgejo-Signature, the hex HMAC without prefix.
  • Delivery id: X-Gitea-Delivery or X-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>. Several v1 are accepted while you roll the secret at Stripe; v0 is 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_verification call is answered by Timon itself, after checking its signature, by echoing its challenge. Your handler never sees it.
  • Delivery id: the body's fingerprint. A slash command is form-encoded: read req.text, not req.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 webhookTimestamp in the signed body (the Linear-Timestamp header 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 get 401, 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 GET carrying hub.mode=subscribe, hub.verify_token and hub.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 200 with that value as the JSON body (API 8). This is how a Discord interaction gets its message, or a Slack command its text. Timon sets Content-Type: application/json itself, 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 502 and the service retries the same delivery: make your handler safe to run twice with the same req.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 with ctx.outbox.post.

Response codes​

CodeWhen
200 {"status":"received"} or your valueThe handler resolved
200 {"status":"duplicate"}This delivery was already handled; the handler is not called again
200 with the handshake replySlack 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-AfterThe same delivery arrived while its first call is still running
413Body larger than 256 KiB
429 + Retry-AfterToo 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 from github to gitea), the stored secret stops working at once: the webhook answers 401 and 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 carry origin: { kind: 'plugin', id: ctx.id }, so your own event handlers can skip them.