Skip to content

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.

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.

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.

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
}

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 ) )
} )

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.

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.

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.