Job webhooks
A job app (see Job apps) can have one webhook: a secret URL that starts the job when another system POSTs to it.
The secret URL
Enabling the webhook gives a URL of the form
POST https://<console host>/v1/hooks/<hookId>/<token>hookIdis 128-bit hex andtokenis 256-bit base64url. Only the token's SHA-256 is stored, so the full URL is shown once, when it is made. Rotating it keeps the hook id and replaces the token.The sender needs no headers:
shcurl -X POST 'https://console.example.com/v1/hooks/<hookId>/<token>' \ -H 'content-type: application/json' -d '{"invoice":42}'Anyone with the URL can start the job. Keep it private, and add an allow-list or a check below when the sender supports one. The token is replaced by
…in every log the platform writes, but a sender or a proxy in between may log the URL.No replay protection on the URL alone. A captured request can be sent again and starts another run, within the rate limit. Replays are refused only with a signature check that has a timestamp header, and retries are deduplicated only with a delivery-id header. Make the job idempotent otherwise.
What the job sees
ctx.triggeris"webhook".ctx.paramsis the body: a JSON object as is; anapplication/x-www-form-urlencodedbody as its fields (a repeated field becomes a list); anything else as{ raw: "<text>" }. Bodies are at most 64 KiB.ctx.webhook = { checks, deliveryId?, event? }.checksis["url"]plus"basic"and"hmac"when the hook has them.deliveryIdandeventare the values of the configured delivery-id and event headers.- No other request header reaches the job.
Responses
| Status | When |
|---|---|
202 {runId} | a run started |
200 {duplicate: true} | the delivery id was seen in the last 24 hours; no run |
409 {skipped, runId} | a run is already going; the skip is recorded |
401 (no body) | any failure: wrong or missing token, unknown hook, failed check, blocked address, replayed signature |
413 | body over 64 KiB |
429 (Retry-After: 60) | over the per-hook rate (instance setting hookRatePerMinute, default 60) |
503 | the app or org is suspended |
Every failure gets the same bare 401 after the same work, so a response never says whether a hook id exists or which check failed.
Optional checks
They add to the URL token; none replaces it.
IP allow-list. Up to 32 IPs or CIDRs (IPv4 or IPv6). The platform checks the client address it resolves itself (trusted proxies honoured), so a sender can't claim another address.
Basic auth. The request must carry
Authorization: Basicwith usertotisand a generated password (shown once). Use it when the sender takes a user and password but no other header.Signature (hmac). The request must carry an HMAC of the raw body, signed with a shared secret, in a header the sender chooses. You set:
Field Meaning header the header that carries the signature algorithm sha256(default) orsha1(weaker; only for senders that need it)encoding hex(default) orbase64prefix text before the signature, such as sha256=timestamp header signs timestamp + "." + bodyinstead of the body and refuses requests more than 5 minutes off; a replayed signature is refused. Naming the signature header itself reads one combinedt=<ts>,v1=<sig>header (any listedv1may match)delivery-id header a repeated id within 24 hours answers 200 {duplicate: true}without a runevent header its value reaches the job as ctx.webhook.eventPaste the sender's secret, or leave it empty and one is generated and shown once. Either way it is kept encrypted in the vault and never shown again.
Connect GitHub
- In the job's Triggers tab, Enable webhook and copy the URL.
- Under Extra checks, choose Require a signature and fill in:
- header
X-Hub-Signature-256, algorithmsha256, encodinghex, prefixsha256=; - delivery-id header
X-GitHub-Delivery, event headerX-GitHub-Event; - leave the secret empty to generate one, and copy it.
- header
- In GitHub, Settings → Webhooks → Add webhook: paste the URL as the payload URL, choose content type
application/json, and paste the secret. - Optional: add GitHub's hook ranges (the
hookslist fromhttps://api.github.com/meta) to the allow-list.
GitHub retries a failed delivery with the same X-GitHub-Delivery; a retry of one that already started a run answers 200 {duplicate: true}. Its first ping event starts a run like any other; branch on ctx.webhook.event:
ts
export default {
async run(ctx, env) {
if (ctx.webhook?.event === "ping") return;
if (ctx.webhook?.event === "push") await deploy(env, ctx.params.ref);
},
};From the CLI:
sh
totis jobs webhook enable ghsync --hmac-header X-Hub-Signature-256 --hmac-prefix sha256= \
--hmac-delivery-header X-GitHub-Delivery --hmac-event-header X-GitHub-EventConnect Stripe
Stripe makes the signing secret, so the order is reversed.
- Enable the webhook and copy the URL.
- In Stripe, Developers → Webhooks → Add endpoint: paste the URL and choose the events. Then reveal the endpoint's signing secret (
whsec_…). - Back in Triggers, Require a signature and fill in:
- header
Stripe-Signature, and timestamp headerStripe-Signaturetoo (Stripe sends onet=…,v1=…header); - algorithm
sha256, encodinghex, no prefix; - paste the
whsec_…secret.
- header
Stripe's event type is in the body: the job reads ctx.params.type. Stripe signs each retry anew, so a captured request replayed later is refused by the 5-minute window and the seen-signature check.
From the CLI (the secret is read from stdin, never the command line):
sh
printf %s "$STRIPE_WHSEC" | totis jobs webhook enable billing \
--hmac-header Stripe-Signature --hmac-timestamp-header Stripe-Signature --secret-stdinOther senders
- A URL field only (form tools, uptime monitors, IoT): use the URL as it is, plus an allow-list if the sender publishes its addresses.
https://user:pass@host/…only: turn on basic auth and give the senderhttps://totis:<password>@<console host>/v1/hooks/<hookId>/<token>. The password then travels in the URL like the token.- Its own HMAC header: fill in the signature fields from the sender's docs. Schemes that sign something other than the body (Slack's
v0:ts:body, Twilio's URL plus parameters) are not supported.
Testing
totis jobs webhook-test [APP] posts to the real URL the way a sender would, so every check runs. It reads the URL (or just the token) from stdin or --from-file, or --rotate mints a new one. For a hook with checks, pass --hmac-secret-file F and --basic-file F.