Skip to content

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>
  • hookId is 128-bit hex and token is 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:

    sh
    curl -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.trigger is "webhook".
  • ctx.params is the body: a JSON object as is; an application/x-www-form-urlencoded body 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? }. checks is ["url"] plus "basic" and "hmac" when the hook has them. deliveryId and event are the values of the configured delivery-id and event headers.
  • No other request header reaches the job.

Responses ​

StatusWhen
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
413body over 64 KiB
429 (Retry-After: 60)over the per-hook rate (instance setting hookRatePerMinute, default 60)
503the 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: Basic with user totis and 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:

    FieldMeaning
    headerthe header that carries the signature
    algorithmsha256 (default) or sha1 (weaker; only for senders that need it)
    encodinghex (default) or base64
    prefixtext before the signature, such as sha256=
    timestamp headersigns timestamp + "." + body instead of the body and refuses requests more than 5 minutes off; a replayed signature is refused. Naming the signature header itself reads one combined t=<ts>,v1=<sig> header (any listed v1 may match)
    delivery-id headera repeated id within 24 hours answers 200 {duplicate: true} without a run
    event headerits value reaches the job as ctx.webhook.event

    Paste 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 ​

  1. In the job's Triggers tab, Enable webhook and copy the URL.
  2. Under Extra checks, choose Require a signature and fill in:
    • header X-Hub-Signature-256, algorithm sha256, encoding hex, prefix sha256=;
    • delivery-id header X-GitHub-Delivery, event header X-GitHub-Event;
    • leave the secret empty to generate one, and copy it.
  3. In GitHub, Settings → Webhooks → Add webhook: paste the URL as the payload URL, choose content type application/json, and paste the secret.
  4. Optional: add GitHub's hook ranges (the hooks list from https://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-Event

Connect Stripe ​

Stripe makes the signing secret, so the order is reversed.

  1. Enable the webhook and copy the URL.
  2. In Stripe, Developers → Webhooks → Add endpoint: paste the URL and choose the events. Then reveal the endpoint's signing secret (whsec_…).
  3. Back in Triggers, Require a signature and fill in:
    • header Stripe-Signature, and timestamp header Stripe-Signature too (Stripe sends one t=…,v1=… header);
    • algorithm sha256, encoding hex, no prefix;
    • paste the whsec_… secret.

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-stdin

Other 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 sender https://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.

TinyS3 is AGPLv3 licensed. Cloudflare and Cloudflare Workers are trademarks of Cloudflare, Inc. Amazon S3 and AWS are trademarks of Amazon.com, Inc. or its affiliates. MinIO is a trademark of MinIO, Inc. They are named only to describe compatibility; totis.ai is not affiliated with, endorsed by, or sponsored by any of them.