Skip to content

Callbacks

Instead of polling, give an async job a callbackUrl and the finished job is POSTed to you, signed with the open Standard Webhooks scheme — the same scheme Emithook uses, so any Standard Webhooks library verifies it.

bash
curl -X POST https://api.servicelabs.dev/v1/utilities/split-pdf/jobs \
  -H "Authorization: Bearer $DU_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "input": { "url": "s3://finnoto-data/uploads/statement.pdf" },
    "metadata": { "statementId": "ST-2026-09" },
    "callbackUrl": "https://svc.finnoto.com/hooks/developer-utils"
  }'

Callbacks are for async jobs (POST …/jobs, or Prefer: respond-async). A sync call already returns the result in its response.

When a callback is sent

Job outcomeCallback
succeededjob.succeeded
failed (including lost)job.failed
cancellednone — you cancelled it

The request we send

http
POST /hooks/developer-utils HTTP/1.1
Host: svc.finnoto.com
Content-Type: application/json
User-Agent: ServiceLabs-Webhooks/1.0
webhook-id: job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3
webhook-timestamp: 1789061410
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

{
  "type": "job.succeeded",
  "timestamp": "2026-09-10T17:30:10.801Z",
  "data": { "id": "job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3", "object": "job", "status": "succeeded", "result": { "files": [  ] },  }
}
HeaderMeaning
webhook-idThe job id. Identical on every retry of the same callback — use it to de-duplicate.
webhook-timestampUnix seconds when this attempt was signed. Reject anything too old (we recommend 5 minutes) to stop replays.
webhook-signaturev1,<base64 HMAC-SHA256> over {webhook-id}.{webhook-timestamp}.{raw body}.

data is exactly what GET /v1/jobs/{id} returns — the full job object, including your metadata.

The signing secret

Each API key has its own callback signing secret (whsec_…). Jobs created with a key are signed with that key's secret, so one key holder can never forge callbacks for another key's jobs.

  • It's shown once when you create the key, next to the key itself.
  • Afterwards, anyone who can manage the key can view or rotate it: API keys → Signing secret. Rotation takes effect for the next callback attempt — deploy the new secret to your receiver first, or accept both during the switch.
  • Jobs started from the console Playground (no API key) are signed with the workspace's console playground secret, shown on the API keys page to staff.

Verify it

Verify the signature against the raw request body — before any JSON parsing or re-serialising, which changes the bytes.

js
import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.DU_WEBHOOK_SECRET // whsec_…
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64')

function verify(rawBody, headers) {
  const id = headers['webhook-id']
  const ts = Number(headers['webhook-timestamp'])
  if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) return false // 5-minute tolerance
  const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest()
  return String(headers['webhook-signature'] ?? '')
    .split(' ')
    .some((part) => {
      const [version, sig] = part.split(',')
      if (version !== 'v1' || !sig) return false
      const given = Buffer.from(sig, 'base64')
      return given.length === expected.length && crypto.timingSafeEqual(given, expected)
    })
}

const app = express()
app.post('/hooks/developer-utils', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body.toString('utf8'), req.headers)) return res.sendStatus(401)
  const event = JSON.parse(req.body)
  res.sendStatus(204) // acknowledge fast, then work
  handle(event.type, event.data) // de-duplicate on event.data.id
})
js
import { Webhook } from 'standardwebhooks'

const wh = new Webhook(process.env.DU_WEBHOOK_SECRET) // whsec_…
app.post('/hooks/developer-utils', express.raw({ type: 'application/json' }), (req, res) => {
  const event = wh.verify(req.body, req.headers) // throws if invalid or too old
  res.sendStatus(204)
  handle(event.type, event.data)
})
python
import base64, hashlib, hmac, json, os, time
from flask import Flask, request, abort

KEY = base64.b64decode(os.environ["DU_WEBHOOK_SECRET"].removeprefix("whsec_"))
app = Flask(__name__)

def verify(raw: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id", "")
    ts = headers.get("webhook-timestamp", "")
    if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    signed = f"{msg_id}.{ts}.".encode() + raw
    expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
    return any(
        part.startswith("v1,") and hmac.compare_digest(part[3:], expected)
        for part in headers.get("webhook-signature", "").split(" ")
    )

@app.post("/hooks/developer-utils")
def hook():
    raw = request.get_data()
    if not verify(raw, request.headers):
        abort(401)
    event = json.loads(raw)
    handle(event["type"], event["data"])  # de-duplicate on event["data"]["id"]
    return "", 204

Official Standard Webhooks libraries exist for most languages (standardwebhooks on npm and PyPI, Go, Java, PHP, Ruby, C#…). Because the scheme is standard, an Emithook endpoint set to verify Standard Webhooks signatures with this secret can receive them too — handy if you want Emithook to receive, log and fan callbacks out for you.

Delivery and retries

  • Answer with any 2xx within 10 seconds. Acknowledge first, then do slow work.
  • Anything else — a non-2xx status, a timeout, a connection error — is retried with exponential back-off: 8 attempts starting at 10 s (10 s, 20 s, 40 s, … ≈ 21 minutes in total).
  • Redirects are not followed. A 3xx counts as a failure — point callbackUrl at the final URL.
  • After the last attempt the job's callback.status becomes failed. The job itself is unaffected: its result is still there via GET /v1/jobs/{id}.
  • The same callback can arrive more than once (e.g. your 2xx was lost in transit). Make the handler idempotent on webhook-id.

Watch deliveries in the job's callback field ({ url, status: pending | delivered | failed, attempts }) or on the job's timeline in the console, where every attempt is listed with its HTTP status and duration.

Allowed destinations

callbackUrl must be:

  • https:// — plain http is refused.
  • Publicly routable. Hostnames are resolved and refused if any address is private (10/8, 172.16/12, 192.168/16, 100.64/10), loopback, link-local (incl. 169.254.169.254), multicast or IPv6 unique-local. This is checked when you submit (shape) and again before every delivery attempt (DNS), so a hostname can't be re-pointed at an internal address later.
  • Free of embedded credentials (https://user:pass@… is refused). Put any shared secret in the path or query instead — or better, rely on the signature.

A callback to a refused destination fails immediately without retries.

Callbacks are a notification, not the source of truth

If your receiver was down past the retry window, nothing is lost — GET /v1/jobs?status=succeeded and GET /v1/jobs/{id} still have every result for the retention period. A periodic reconcile against the jobs list makes an integration fully robust.

ServiceLabs · a Finnoto company