Webhooks
Webhooks let you skip polling. We POST you a JSON payload whenever an async job finishes — success or failure.
Register an endpoint
Section titled “Register an endpoint”Open surveycoder.io/webhooks or call the API:
curl -X POST https://api.surveycoder.io/v1/webhooks \ -H "x-api-key: $SCP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/surveycoder", "events": ["job.completed", "job.failed"] }'The URL must be HTTPS. events defaults to ["job.completed", "job.failed"] if omitted; you can also pass ["*"] to receive every event.
The response includes a secret. Store it immediately — we never show it again. To rotate, delete the webhook and register a new one.
Events
Section titled “Events”| Event | When it fires | Payload |
|---|---|---|
job.completed | Async job finishes successfully | { event, job_id, project_id, question_id, result: { responses_coded, credits_used }, timestamp } |
job.failed | Async job fails terminally | { event, job_id, error: { code, message }, timestamp } |
Verifying signatures
Section titled “Verifying signatures”Every delivery includes an X-Signature header of the form sha256=<hex> — an HMAC-SHA256 of the raw request body using your webhook secret. Always verify it. Two other headers come along for context: X-Event (the event name) and X-Attempt (the retry number, 1 for the first attempt).
import express from 'express';import crypto from 'node:crypto';
const SECRET = process.env.SCP_WEBHOOK_SECRET!;
const app = express();app.post('/hooks/surveycoder', express.raw({ type: 'application/json' }), // raw body is critical (req, res) => { const header = req.header('X-Signature') ?? ''; const signature = header.startsWith('sha256=') ? header.slice(7) : ''; const expected = crypto .createHmac('sha256', SECRET) .update(req.body) .digest('hex');
const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8')); // handle event... res.status(200).end(); });import hmac, hashlib, osfrom fastapi import FastAPI, Request, HTTPException
app = FastAPI()SECRET = os.environ["SCP_WEBHOOK_SECRET"].encode()
@app.post("/hooks/surveycoder")async def hook(request: Request): body = await request.body() header = request.headers.get("x-signature", "") signature = header[len("sha256="):] if header.startswith("sha256=") else "" expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(signature, expected): raise HTTPException(status_code=401) event = await request.json() # handle event... return {"ok": True}Retry schedule
Section titled “Retry schedule”If your endpoint returns 5xx (or times out after 10s), we retry. 4xx is treated as a permanent failure and is not retried (the delivery counts as accepted from our side). On a retry-eligible failure:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | +5s |
| 3 | +30s |
| 4 | +5m |
After the 4th attempt we drop the delivery. The X-Attempt header on each request tells you which attempt this is.
Idempotency on your side
Section titled “Idempotency on your side”We can deliver the same event more than once — for example, if your endpoint returned 200 but our network connection dropped before we recorded the success. The payload includes job_id and event — use the pair as a deduplication key:
- Read
job_idandeventfrom the JSON body. - Check whether you’ve processed that combination already (e.g. table
processed_webhookswith a unique constraint on(job_id, event)). - If yes, return
200and exit — don’t re-run side effects. - If no, process the event, then record the pair.
Local development
Section titled “Local development”Use ngrok, Cloudflare Tunnel, or the Replay button in the dashboard to develop against real events without exposing a public URL.
Related
Section titled “Related”- Idempotency on outgoing requests
SERVICE_UNAVAILABLE— temporary failures don’t drop webhooks; they just delay them