Implement a webhook receiver
This guide walks through building a webhook receiver that handles RCM events correctly under production conditions: signature verification, idempotency, fast acknowledgement, and async processing. The naive "verify and process inline" approach works in dev but breaks the moment something downstream is slow.
By the end, you'll have:
- A receiver endpoint that returns 2xx in under a second.
- Dedup guarantees against retried events.
- A clean separation between webhook ingestion and your business logic.
Recommended architecture
Two things matter:
- The HTTP handler is dumb on purpose. All it does is verify authentication, write the event to a durable queue (or a log table), and ack with 200. No business logic. This way you ack quickly regardless of how slow downstream is.
- The worker is idempotent. It reads events from the queue and applies them to your app store, keyed on
event_id.
If you only have one process today (no queue), the same shape applies: write to a webhook_events table with event_id as primary key, ack, and have a separate cron/poller process the unprocessed rows.
Step-by-step
1. Receive the request
Pick a clean URL — POST /webhooks/osigu-rcm is conventional. Tell OSIGU to register it as your sandbox subscription destination, plus the corresponding production URL when you go live.
2. Read the raw body before parsing
This is the #1 thing developers get wrong. The HMAC is computed over the bytes of the body as received, not over a re-serialised JSON. Most frameworks aggressively auto-parse application/json, which destroys whitespace and changes the bytes.
Framework-specific notes:
| Framework | How to get raw bytes |
|---|---|
| Express (Node.js) | app.use('/webhooks/...', express.raw({type: 'application/json'})) before express.json() |
| FastAPI (Python) | await request.body() — returns bytes |
| Spring Boot (Java) | @RequestBody byte[] rawBody — declare the param as byte[], not as a DTO |
| Rails (Ruby) | request.raw_post |
| Go net/http | io.ReadAll(r.Body) |
| PHP | file_get_contents('php://input') |
3. Verify authentication
For HMAC subscriptions (default), reject with 401 if any of these is wrong:
X-RCM-Signatureis missing or doesn't match the Base64 HMAC-SHA256 oftimestamp + raw_body(concatenated directly, no separator) using your shared secret.X-RCM-Timestamp(ISO-8601) is more than 5 minutes off your server clock.- The comparison was done with
==instead of a constant-time function.
For Basic subscriptions, validate the Authorization: Basic header against the agreed credentials instead.
Full details in Authentication and Signature verification.
4. Persist the event, dedup on event_id
Before doing anything else, write the event to durable storage keyed on event_id:
CREATE TABLE webhook_events (
event_id VARCHAR(40) PRIMARY KEY,
event_type VARCHAR(80) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
raw_body JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
The PRIMARY KEY on event_id is the dedup guarantee — if the same event arrives twice, the second INSERT fails on the unique constraint and you safely ack the duplicate without processing it again.
5. Acknowledge with 200 immediately
After the row is written, return 200 OK. No business logic in the handler. Downstream services can be slow, your message broker can be slow, your database can be slow — none of it should affect ack latency.
// Pseudocode
app.post('/webhooks/osigu-rcm', async (req, res) => {
if (!authIsValid(req)) return res.status(401).send('bad auth');
const event = JSON.parse(req.body.toString('utf8'));
try {
await db.query(
'INSERT INTO webhook_events (event_id, event_type, created_at, raw_body) ' +
'VALUES ($1, $2, $3, $4)',
[event.event_id, event.event_type, event.created_at, req.body.toString('utf8')]
);
} catch (err) {
if (err.code === '23505') {
// Unique violation — duplicate. Ack to stop RCM from retrying.
return res.status(200).send('duplicate, already processed');
}
// Real DB error — return 5xx so RCM retries.
return res.status(500).send('persist failed');
}
// Optionally enqueue to a worker queue here for faster pickup.
// await queue.publish('webhook-events', event.event_id);
res.status(200).send('ok');
});
6. Process events from the queue/table
A separate worker reads unprocessed rows and applies the business logic:
// Worker pseudocode — runs on a schedule or consumes from a queue
async function processEvent(eventId) {
const event = await db.query(
'SELECT * FROM webhook_events WHERE event_id = $1 AND processed_at IS NULL',
[eventId]
);
if (!event) return; // already processed by another worker
switch (event.event_type) {
case 'DOCUMENT_VALIDATION_COMPLETED':
await markSupportFileApproved(event.raw_body.payload);
break;
case 'DOCUMENT_VALIDATION_REJECTED':
await markSupportFileRejected(event.raw_body.payload);
await notifyUserOfRejection(event.raw_body.payload);
break;
case 'DOCUMENT_CLASSIFICATION_COMPLETED':
await setDetectedDocumentType(event.raw_body.payload);
break;
case 'DOCUMENT_CLASSIFICATION_FAILED':
await markSupportFileUnclassified(event.raw_body.payload);
break;
default:
// Unknown event type — log and move on. Don't 5xx; new event types
// may be added over time.
log.warn(`unknown event type ${event.event_type}`);
}
await db.query(
'UPDATE webhook_events SET processed_at = now() WHERE event_id = $1',
[eventId]
);
}
Handling unknown event types
RCM may add new event types over the life of the v1 API (they're additive, not breaking). Your switch statement above falls through to a default that logs the event and returns 200 — don't 5xx on event types you don't recognise, or RCM will keep retrying them forever.
A good rule of thumb: persist every event you receive, regardless of whether you recognise it. If a year from now OSIGU adds a new event type and your worker doesn't know how to process it, the row is still in webhook_events and you can backfill processing later.
Local testing
You need a publicly reachable HTTPS URL to receive webhooks. Two options:
ngrok (simplest)
# Install once
brew install ngrok # macOS
# Expose your local server
ngrok http 3000
# ngrok prints something like:
# Forwarding https://abc123-1-2-3-4.ngrok-free.app -> http://localhost:3000
Give the https://abc123-... URL to OSIGU support, who registers it as your sandbox webhook destination. Then upload a test support file via /v1/support-files/upload and watch the webhook land in your local server's logs.
Cloudflare Tunnel (no rate limits, requires CF account)
cloudflared tunnel --url http://localhost:3000
Same idea — gives you a https://random.trycloudflare.com URL. No rate limits unlike ngrok's free tier.
Monitoring and alerting
Once you're live in production, instrument:
- Ack latency: alert if p95 of handler response time > 1 second. Slow handlers risk hitting the subscription's read timeout, which counts as a failed delivery.
- Non-2xx rate: alert if > 0.1% of webhooks return non-2xx — every failure triggers RCM to retry, which adds load and can trip the circuit breaker.
- Unprocessed events: alert if any row in
webhook_eventshasprocessed_at IS NULLfor more than a few minutes. - Unknown event types: log + low-priority alert. Tells you OSIGU shipped a new event you might want to handle.
- Signature verification failures: log + alert if > 0. Either someone's spoofing your URL or your secret rotated wrong.
Secret rotation without downtime
Eventually you'll want to rotate the HMAC secret. Coordinate the new secret with the Integraciones team (it is provisioned out of band, not returned by the API). The trick is dual verification during the cutover window:
- Obtain the new secret from Integraciones for the subscription.
- Update your verifier to accept signatures from either the old secret OR the new one. Try the new one first; if it doesn't match, fall back to the old one.
- Wait until you've seen at least one event verified with the new secret (logs will show which one matched).
- Remove the old secret from your verifier code.
No event drops during the cutover. (Basic-auth subscriptions rotate the same way — swap basic_auth_client_secret via an update and accept both during the window.)
Common mistakes
- Doing business logic inside the HTTP handler. Causes timeouts under load, triggers retry storms.
- Parsing JSON before computing the HMAC. Re-serialised JSON differs byte-for-byte from what was sent; signature never matches.
- Using
==to compare signatures. Timing-vulnerable. Always usecrypto.timingSafeEqual(Node),hmac.compare_digest(Python),MessageDigest.isEqual(Java). - Acknowledging before persisting. If the persist fails after the ack, you've silently lost an event. Always persist first.
- Returning 4xx for unknown event types. RCM retries 4xx (it's not just 5xx). Return 200 and log.
- Storing the secret in code. Use a secret manager (AWS Secrets Manager, Vault, k8s secrets). Don't commit it.