Webhooks
Registering an endpoint, verifying the signature, and every event type the platform emits.
Webhooks are the event log, delivered to a URL you own, signed so your listener can prove it came from Nomi, and retried when your service is having a bad afternoon.
POST/v1/webhook_endpoints
Register a URL. The signing secret is returned once, here.
Registering
curl -X POST https://api.nomi-tech.com/v1/webhook_endpoints \
-H "Authorization: Bearer nomi_sk_live_EXAMPLE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example/hooks/nomi",
"description": "Production listener",
"eventTypes": ["credential.*"]
}'An empty eventTypes means every event. credential.* matches a whole family, so a new event type does not require re-listing them all.
Verifying the signature
Each delivery carries a nomi-signature header of the form t=<unix seconds>,v1=<hex>. v1 is an HMAC-SHA256 over ${t}.${rawBody} using the endpoint's secret. The timestamp is inside the HMAC, so a valid signature cannot be replayed forever.
A verifier
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string) {
const parts = new Map(
header.split(",").map((p) => {
const [k, ...rest] = p.trim().split("=");
return [k, rest.join("=")] as const;
}),
);
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(v1, "hex");
const mine = Buffer.from(expected, "hex");
if (given.length !== mine.length || !timingSafeEqual(given, mine)) return false;
// Freshness. The signature proves origin; the timestamp proves it is not a replay.
return Math.abs(Date.now() / 1000 - Number(t)) <= 300;
}Event types
GET /v1/webhook_event_types returns the list your account can subscribe to, which is the one to build against. The families below are the ones an integration normally wants:
| Family | Types |
|---|---|
credential.* | created, issued, updated, suspended, resumed, revoked, expired, verified, verify_refused, shared, viewed |
credential.channel.* | provisioned, installed, removed, failed |
subject.* | created, updated, deactivated, deleted |
template.version.* | published, archived, unarchived |
achievement.* | created, updated, version.published, version.archived |
policy.* | applied, halted, failed |
group.* | created, updated, deleted, members_added, members_removed |
webhook.endpoint.* | created, updated, deleted, disabled |
Delivery and failure
A delivery your listener does not accept is retried, so an afternoon of downtime does not become a day of lost events. What your side has to get right is on this page and in Webhook security: verify the signature, answer quickly, and make the handler idempotent, because a retry means your service will see the same event twice.
The operational half — reviewing what was attempted, sending one again after a fix, sending a test delivery, and rotating the signing secret — is done from the console by whoever administers the organisation. None of it needs a deploy on your side.
An endpoint that keeps failing is eventually disabled, and that emits webhook.endpoint.disabled — so silence is a thing you can be told about rather than a thing you discover.