Customers · Guides
Poll or get a webhook
Learn that a run has ended: read it every few seconds, or have Sushibelt call your HTTPS endpoint and verify its signature.
- Before you start
- An API key with the runs:create and runs:read scopes.
- For webhooks: an HTTPS endpoint that Sushibelt can reach.
- You'll end with
- Your code hears when each run completes or fails, once, from a delivery it has verified.
A run takes from a few seconds to many minutes. You can ask how it is going (polling), or give Sushibelt a URL to call when it ends (a webhook). Polling is simpler and works anywhere; a webhook needs a public HTTPS endpoint but saves you the requests. Many integrations use both: a webhook to hear quickly, and a slow poll as a fallback.
Polling#
Read the run with Get a run until status is completed or failed.
- A few seconds apart is enough. Start at two seconds and slow down to ten for long runs.
- Mind the rate limit. A key may make 60 requests a minute by default, and every request counts, polling included. Several runs polled at once share that budget.
- Set a time limit. A run ends as
completed,failed,stoppedorcancelled— but a limit keeps a slow provider from holding your code open. - Reading is free. It never spends credits, and a key that has reached its monthly cap can still read the runs it started.
JOB_ID="YOUR_JOB_ID"
curl "https://app.sushibelt.ai/api/v1/runs/$JOB_ID" \
-H "Authorization: Bearer $SUSHIBELT_API_KEY"Run a workflow from code has a complete polling function in TypeScript and Python.
Webhooks#
Pass callbackUrl when you start a run, and Sushibelt sends a POST to it when the run completes or fails. The URL must use https://; anything else is refused with 422 before the run starts. Add a callbackSecret of your choosing and each delivery is signed with it.
WORKFLOW_ID="YOUR_WORKFLOW_ID"
curl -X POST "https://app.sushibelt.ai/api/v1/workflows/$WORKFLOW_ID/runs" \
-H "Authorization: Bearer $SUSHIBELT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"callbackUrl": "https://example.com/hooks/sushibelt",
"callbackSecret": "YOUR_WEBHOOK_SECRET"
}'What you receive#
The body is the same as Get a run, read at the moment of sending, plus event. The full schema is in the reference under Run finished.
| Header | Description |
|---|---|
Content-Type | application/json. |
X-Sushibelt-Event | run.completed, run.failed, run.stopped or run.cancelled. The same value as event in the body. |
X-Sushibelt-Signature | Sent only when you passed callbackSecret: sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your secret. |
{
"event": "run.completed",
"jobId": "8b2d6f10-4c3a-4e59-a7d2-91f0c3e8b5a4",
"status": "completed",
"progress": 100,
"steps": [ ... ],
"output": [ ... ]
}Decide what happened from event. There are four events: run.completed, run.failed, run.stopped when someone on your team stops the run in the app, and run.cancelled when someone cancels it.
How to answer, and retries#
- Answer with any
2xxstatus within 8 seconds. Do slow work after you answer. - Anything else — another status, a timeout, a connection error — is retried. The first delivery is attempted shortly after the run ends; after a failure the next attempt waits 2 minutes, then twice as long each time (4, 8, 16 minutes), never more than an hour. After 5 attempts in all, Sushibelt stops trying.
- A delivery can arrive twice. Treat
jobIdandeventtogether as its idempotency key, and ignore a pair you have handled. - There is no log of deliveries to read back. If your endpoint was down for longer than the retries last, read the run with Get a run.
Verify the signature#
- Read the raw body, before any JSON parsing. Parsing and re-serialising changes the bytes, and the signature with them.
- Compute HMAC-SHA256 of the raw body, keyed with your
callbackSecret, and hex-encode it. - Compare it with the value after
sha256=inX-Sushibelt-Signature, in constant time. If they differ, answer401and ignore the body.
// webhook.mts: Node 18 or later. Run with: npx tsx webhook.mts
import { createServer } from 'node:http'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.SUSHIBELT_WEBHOOK_SECRET ?? ''
const seen = new Set<string>() // in production, keep this in your database
function signatureIsValid(rawBody: Buffer, header: string | undefined): boolean {
if (!header?.startsWith('sha256=')) return false
const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex')
const given = header.slice('sha256='.length)
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}
createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
const raw = Buffer.concat(chunks) // verify the raw bytes, before parsing
if (!signatureIsValid(raw, req.headers['x-sushibelt-signature'] as string | undefined)) {
res.writeHead(401).end()
return
}
res.writeHead(200).end() // answer at once; do slow work afterwards
const delivery = JSON.parse(raw.toString('utf8'))
const id = `${delivery.jobId}:${delivery.event}`
if (seen.has(id)) return // a repeat of a delivery you already handled
seen.add(id)
console.log(`${delivery.event}: run ${delivery.jobId}`)
})
}).listen(3000)Warning: the secret travels with each run
Use a long random value, keep it in a secret store, and pass it only over HTTPS. Anyone with the secret can sign a body your endpoint will believe.