Skip to main content

Implement a webhook receiver

Osigu delivers async outcomes — sponsor decisions, dispensation completions, prescription email deliveries — as webhook events to an HTTPS endpoint your team owns. This guide walks the receiver end-to-end: signature verification, dedupe, and returning quickly.

Concept refresher: Webhooks. Full event catalogue: Webhook events.

Prerequisites

  • An HTTPS-reachable endpoint — Osigu will not deliver to plain HTTP.
  • A webhook signing secret from Osigu (send webhooks@osigu.com the URL you want events delivered to and we'll issue you one).
  • A request-body persistence you can query on retries (postgres, redis, whatever).

1. Register the endpoint

Once onboarding sends you the signing secret, register your endpoint URL:

curl -X POST "https://sandbox.osigu.com/webhooks/v1/subscriptions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.myprovider.com/osigu/webhooks",
"event_types": ["authorization.*", "dispensing.*"]
}'

event_types accepts glob-style patterns. Use ["*"] to subscribe to everything.

2. Verify the signature

Every delivery carries:

X-Osigu-Signature: t=1751385600,v1=8b7c…hex_hmac_sha256

Rules:

  1. Compute HMAC-SHA256(secret, "${t}.${raw_body}") where t is the value from the header and raw_body is the exact bytes of the request body.
  2. Compare hex-encoded HMAC to the v1= value with a constant-time compare.
  3. Reject if the t timestamp is more than 5 minutes off from now.

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.OSIGU_WEBHOOK_SECRET;

app.post(
'/osigu/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Osigu-Signature') ?? '';
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=')),
);
const t = Number(parts.t);
const v1 = parts.v1;

if (!t || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(401).send('timestamp');
}

const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${req.body.toString()}`)
.digest('hex');

if (
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1 ?? ''))
) {
return res.status(401).send('signature');
}

const event = JSON.parse(req.body.toString());
// ... dedupe on event.event_id, enqueue work, respond ...
return res.status(200).send('ok');
},
);

Python (FastAPI)

import hmac, hashlib, os, time
from fastapi import FastAPI, Request, HTTPException

SECRET = os.environ["OSIGU_WEBHOOK_SECRET"].encode()
app = FastAPI()

@app.post("/osigu/webhooks")
async def webhook(req: Request):
raw = await req.body()
header = req.headers.get("X-Osigu-Signature", "")
parts = dict(kv.split("=") for kv in header.split(","))
t = int(parts.get("t", 0))
v1 = parts.get("v1", "")

if abs(time.time() - t) > 300:
raise HTTPException(401, "timestamp")

expected = hmac.new(SECRET, f"{t}.{raw.decode()}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
raise HTTPException(401, "signature")

# ... dedupe, enqueue, return ...
return {"ok": True}

Go

func webhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
parts := map[string]string{}
for _, kv := range strings.Split(r.Header.Get("X-Osigu-Signature"), ",") {
p := strings.SplitN(kv, "=", 2)
if len(p) == 2 { parts[p[0]] = p[1] }
}
t, _ := strconv.ParseInt(parts["t"], 10, 64)
if math.Abs(float64(time.Now().Unix()-t)) > 300 {
http.Error(w, "timestamp", 401); return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("OSIGU_WEBHOOK_SECRET")))
fmt.Fprintf(mac, "%d.%s", t, body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts["v1"])) {
http.Error(w, "signature", 401); return
}
// dedupe, enqueue, return
w.WriteHeader(200)
}

3. Dedupe on event_id

The same event can arrive more than once. Persist processed event_ids (with a TTL of ~7 days) and short-circuit on duplicates:

-- On receive:
INSERT INTO webhook_events (event_id, received_at) VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
-- If no row returned, it's a duplicate — return 200 immediately, don't reprocess.

4. Respond fast, work async

Return 200 in under 10 seconds — ideally under 1 second. Do the heavy work off the request thread (background job, message queue). If Osigu times out, we'll retry, which means duplicates for you to dedupe.

A minimal pattern:

POST → verify → dedupe → enqueue job → return 200

5. Retry behaviour

Osigu retries on any non-2xx for 72 hours with exponential backoff:

  • 1st retry ~1 min later
  • 2nd retry ~5 min later
  • Then progressively longer intervals up to ~4h
  • After 72h of failures, the event is abandoned and an alert fires on Osigu's side. You'll be contacted.

6. Testing locally

Use ngrok (or Cloudflare Tunnel / tailscale funnel) to expose your dev machine on HTTPS:

ngrok http 3000

Register the ngrok URL as your sandbox webhook subscription. Trigger a sandbox event (a completed dispensation, an OTP-validated auth) and confirm you get the callback.