Result webhooks
Webhooks are the recommended way to receive calculation results. Instead of polling /v3/result/ready until a job finishes, SmartCut sends the finished result to a URL you control, as soon as it is ready.
Register a webhook
Section titled “Register a webhook”There are two ways to set one up, and they can be combined.
Per-request — include a webhook field in your /v3/calculate request body:
{ "saw": { "cutType": "guillotine", "bladeWidth": 3, "stockType": "sheet" }, "stock": [ { "l": 2400, "w": 1200 } ], "parts": [ { "l": 400, "w": 300, "q": 5 } ], "webhook": "https://example.com/hooks/smartcut"}Account default — set a default URL at smartcut.dev/account. It is used for every job that does not carry a per-request URL.
A per-request webhook wins for that job. If you set neither, nothing is delivered and you must poll for the result.
What gets delivered
Section titled “What gets delivered”When the job finishes, SmartCut sends an HTTP POST with a Content-Type: application/json body. For a successful calculation the body is identical to the response from GET /v3/result — the same ResultResponse schema — with jobId set, so you can correlate it with the job id you got back from /v3/calculate.
Every request carries these headers:
| Header | Description |
|---|---|
X-SmartCut-Event |
The event name — job.completed or job.failed |
X-SmartCut-Delivery |
Unique delivery ID. Stable across retries — use it as an idempotency key |
X-SmartCut-Attempt |
Which attempt this is, starting at 1 |
X-SmartCut-Timestamp |
Unix timestamp (seconds) of this delivery attempt |
X-SmartCut-Signature |
HMAC signature — see Verifying the signature |
Respond with any 2xx status within 10 seconds to acknowledge. Anything else counts as a failure and is retried.
Events
Section titled “Events”Read X-SmartCut-Event to tell the two apart — the body shape differs.
| Event | Body |
|---|---|
job.completed |
The full ResultResponse, exactly as GET /v3/result returns it |
job.failed |
{ "statusCode": 422, "message": "<reason the job failed>" } |
A failed job sends a notification and nothing else — there is no partial result to collect. Branch on the header before using the body:
// `payload` is the parsed body, after verifying the signature against the raw// bytes — see below.function handle( event, payload ) { if ( event === 'job.failed' ) { console.error( `Job failed: ${ payload.message }` ) return } console.log( `Job ${ payload.jobId } complete` ) // payload is a ResultResponse}Verifying the signature
Section titled “Verifying the signature”Every delivery is signed with a secret unique to your account. You can reveal or rotate it on your account page. Verify the signature before trusting a payload — it is what proves the request came from SmartCut.
The X-SmartCut-Signature header looks like:
X-SmartCut-Signature: t=1720000000,v1=5257a869e7b...v1 is an HMAC-SHA256, in hex, of the string {timestamp}.{raw request body} — using your signing secret as the key. Note the timestamp is inside the signed payload: that’s what lets you reject replayed deliveries.
To verify: take t from the header, concatenate it with a . and the raw request body (before any JSON parsing — re-serialising will change the bytes and break the signature), HMAC it with your secret, and compare against v1 using a constant-time comparison. Reject the request if the timestamp is more than a few minutes old.
import crypto from 'crypto'
function verifySmartCutWebhook( header, rawBody, secret, toleranceSeconds = 300 ){ const parts = Object.fromEntries( header.split( ',' ).map( p => p.trim().split( '=' ) ) ) const timestamp = Number( parts.t ) const provided = parts.v1
// A malformed header can never be valid — bail before the replay check. if ( !Number.isFinite( timestamp ) || !provided ) return false
// Reject replays: the signature is still valid, but the delivery is stale. if ( Math.abs( Math.floor( Date.now() / 1000 ) - timestamp ) > toleranceSeconds ) return false
const expected = crypto .createHmac( 'sha256', secret ) .update( `${ timestamp }.${ rawBody }`, 'utf8' ) .digest( 'hex' )
const a = Buffer.from( expected ) const b = Buffer.from( provided )
return a.length === b.length && crypto.timingSafeEqual( a, b )}Your framework must give you the raw body. In Express, use express.raw({ type: 'application/json' }) on the webhook route and parse it yourself after verifying, rather than express.json() — which discards the exact bytes the signature covers:
app.post( '/hooks/smartcut', express.raw( { type: 'application/json' } ), ( req, res ) => { const raw = req.body // a Buffer, not an object
if ( !verifySmartCutWebhook( req.get( 'X-SmartCut-Signature' ), raw, process.env.SMARTCUT_WEBHOOK_SECRET ) ) { return res.sendStatus( 400 ) }
// Acknowledge first, then do the work — you have 10 seconds. res.sendStatus( 200 ) handle( req.get( 'X-SmartCut-Event' ), JSON.parse( raw ) )} )Retries and idempotency
Section titled “Retries and idempotency”If your endpoint does not return a 2xx, the delivery is retried with an increasing backoff over roughly a day before being given up on. These responses are treated differently:
| Response | Retried? |
|---|---|
2xx |
Delivered — no retry |
5xx |
Yes |
429 / 408 |
Yes — a Retry-After header is honoured |
Other 4xx |
No — resending an unchanged request cannot help |
3xx |
No — redirects are not followed |
| Timeout (10s) or network error | Yes |
Retries reuse the same X-SmartCut-Delivery ID, so treat that ID as an idempotency key and ignore a delivery you have already processed. A retry is not a new result — it is the same one arriving again.
Endpoints that keep failing
Section titled “Endpoints that keep failing”The two registration methods behave differently when your endpoint breaks, and the difference matters:
- Account-default URLs are health-tracked. After sustained failure with no successful delivery in between, the endpoint is disabled and we stop sending to it. You will be emailed before that happens. A disabled endpoint has to be re-enabled by hand on your account page — it is never re-enabled automatically. Any successful delivery resets the clock.
- Per-request URLs are not tracked. They are signed and retried exactly the same way, but a URL that fails is never disabled, and a broken one produces no warning — nothing accumulates across jobs, because each job carries its own URL.
If you rely on webhooks for anything important, prefer the account default so that a silently broken endpoint is surfaced to you rather than failing quietly on every job.
Falling back to polling
Section titled “Falling back to polling”Webhooks require your service to accept inbound HTTP. If it cannot, poll /v3/result/ready every 2-3 seconds, stop on 200 (then fetch /v3/result) or on 404/410, and back off on 429.