Skip to content

Webhooks

Webhooks let you skip polling. We POST you a JSON payload whenever an async job finishes — success or failure.

Open surveycoder.io/webhooks or call the API:

Terminal window
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.

EventWhen it firesPayload
job.completedAsync job finishes successfully{ event, job_id, project_id, question_id, result: { responses_coded, credits_used }, timestamp }
job.failedAsync job fails terminally{ event, job_id, error: { code, message }, timestamp }

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

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:

AttemptDelay
1immediate
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.

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:

  1. Read job_id and event from the JSON body.
  2. Check whether you’ve processed that combination already (e.g. table processed_webhooks with a unique constraint on (job_id, event)).
  3. If yes, return 200 and exit — don’t re-run side effects.
  4. If no, process the event, then record the pair.

Use ngrok, Cloudflare Tunnel, or the Replay button in the dashboard to develop against real events without exposing a public URL.