# Result webhooks

URL: https://smartcut.dev/docs/webhooks

> Receive finished calculation results at your own URL instead of polling — registration, delivery payload, signature verification and retry behaviour for the SmartCut optimisation API.

Webhooks are the recommended way to receive calculation results. Instead of polling [`/v3/result/ready`](/api-docs/v3) until a job finishes, SmartCut sends the finished result to a URL you control, as soon as it is ready.

:::note
This page covers the **optimisation API** webhook, which delivers calculation results and is configured per API user. It is **not** the e-commerce store webhook — that one delivers order and inventory events, is configured in your store settings, and is documented in the [Hosted store guide](/docs/ecommerce#webhooks).
:::

## 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`](/api-docs/v3) request body:

```json
{
  "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](https://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

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](#verifying-the-signature) |

Respond with any `2xx` status within 10 seconds to acknowledge. Anything else counts as a failure and is retried.

## 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:

```js
// `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

Every delivery is signed with a secret unique to your account. You can reveal or rotate it on your [account page](https://smartcut.dev/account). Verify the signature before trusting a payload — it is what proves the request came from SmartCut.

The `X-SmartCut-Signature` header looks like:

```text
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.

```js
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:

```js
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

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

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

Webhooks require your service to accept inbound HTTP. If it cannot, poll [`/v3/result/ready`](/api-docs/v3) every 2-3 seconds, stop on `200` (then fetch `/v3/result`) or on `404`/`410`, and back off on `429`.
