Appearance
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 outcome | Callback |
|---|---|
succeeded | job.succeeded |
failed (including lost) | job.failed |
cancelled | none — 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": [ … ] }, … }
}| Header | Meaning |
|---|---|
webhook-id | The job id. Identical on every retry of the same callback — use it to de-duplicate. |
webhook-timestamp | Unix seconds when this attempt was signed. Reject anything too old (we recommend 5 minutes) to stop replays. |
webhook-signature | v1,<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 "", 204Official 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
3xxcounts as a failure — pointcallbackUrlat the final URL. - After the last attempt the job's
callback.statusbecomesfailed. The job itself is unaffected: its result is still there viaGET /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://— plainhttpis 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.