Best Practices & Troubleshooting
How to build a webhook handler that stays reliable, and how to diagnose deliveries that do not arrive.
Best practices
Respond quickly, process asynchronously
Your endpoint should acknowledge the delivery with a 2xx immediately and hand the actual work to a background job or queue. Slow handlers risk timeouts, which count as failed deliveries and trigger unnecessary retries.
app.post("/webhooks/factorial", (req, res) => {
// 1. Verify
if (req.get("x-factorial-wh-challenge") !== process.env.FACTORIAL_WH_CHALLENGE) {
return res.sendStatus(401);
}
// 2. Persist the raw payload
await rawEvents.insert({ body: req.body, receivedAt: new Date() });
// 3. Acknowledge before doing any real work
res.sendStatus(200);
// 4. Process out of band
await queue.enqueue("factorial-webhook", req.body);
});Make your handler idempotent
Because of retries, your endpoint may receive the same event more than once. Payloads do not carry a unique per-delivery identifier — id is the identifier of the resource — so you cannot deduplicate on it alone.
The reliable approach is to make the operation itself idempotent: converge on the state in the payload rather than applying a delta. An upsert keyed on the resource id produces the same result whether it runs once or five times.
async function handle(resource) {
// Idempotent by construction: same input, same end state
await employees.upsert(
{ factorialId: resource.id },
{
fullName: resource.full_name,
email: resource.email,
active: resource.active,
factorialUpdatedAt: resource.updated_at
}
);
}If you must trigger a side effect that is not safe to repeat — sending an email, creating a payment, kicking off onboarding — guard it with your own deduplication key built from the payload, for example subscription_type plus the resource id plus updated_at. Store that key when the side effect succeeds and skip anything you have already seen.
const dedupeKey = `employees/employee/update:${resource.id}:${resource.updated_at}`;
if (await processedEvents.exists(dedupeKey)) return;
await startOnboardingWorkflow(resource);
await processedEvents.insert(dedupeKey);
Whyupdated_atis in the keyRetries of one delivery carry an identical body, so they produce an identical key and are skipped. A genuine second change to the same resource carries a newer
updated_at, so it produces a new key and is processed.
Log raw payloads
Store the raw incoming payload together with the received timestamp before processing it. This gives you an audit trail and lets you replay events while debugging, without waiting for the event to happen again in Factorial.
Monitor for failures
Set up alerting if your endpoint starts returning errors consistently. Silent failures cause your integration to drift out of sync with Factorial. Combine this with a technical support contact so Factorial's failure emails reach the right team.
Validate before trusting
Verify the x-factorial-wh-challenge header on every request, and treat the payload as untrusted input — validate the fields you rely on rather than assuming they are present.
Keep subscriptions tidy
Periodically list your subscriptions to check what is active. Delete subscriptions you no longer consume, and disable rather than delete when pausing temporarily.
Troubleshooting
| Symptom | Likely cause | What to check |
|---|---|---|
| No events arriving at all | Endpoint unreachable, wrong event type, or the tested action does not trigger the webhook | Confirm the endpoint is publicly reachable over HTTPS; confirm the subscription_type matches the action you are performing; confirm the subscription is enabled |
| Events arriving for some employees only | Permission-scoped token | The subscription inherits the permissions of the token that created it — see Webhook permissions |
Your endpoint returns 400 | Payload parsing or validation issue on your side | Check your JSON parsing, and that you are reading fields from the top level of the body — payloads are not wrapped in an envelope |
Your endpoint returns 401 | Challenge mismatch, or your own auth middleware rejecting the request | Verify the stored challenge matches the subscription; make sure the webhook route is exempt from user authentication |
| Deliveries stopped after a period of errors | Retries were exhausted and the subscription was disabled | Fix the endpoint, then re-enable the subscription; replay any missed events from your logs |
| The same event processed twice | Retries after a slow or failed acknowledgement | Make the handler idempotent and acknowledge faster — see Make your handler idempotent |
| Timeouts under load | Work is being done inside the request | Move processing to a queue and acknowledge immediately |
Testing your endpoint
- Create a subscription pointing at your staging endpoint with a distinctive
name. - Perform the triggering action in Factorial — for the events in this section, that means the actual user action, not an API write that bypasses it.
- Check your raw payload log for the delivery.
- When you are done, delete or disable the staging subscription so it stops accruing failures.
Updated 9 days ago

