Webhooks
<!-- order: 8 | Get told when a verification, certificate, opportunity or the catalog changes: subscribing, checking signatures, retries and replay. -->A webhook sends a signed POST to your address when something changes, so you don't have to keep asking. Add one on Keys and tokens, or with the API (POST /v2/webhooks, scope webhook.manage):
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/fip-webhooks", "events": ["verification.status_changed", "verification.expiring"]}' \
https://api.foodintegrityproject.org/v2/webhooks
The answer includes the webhook's secret, once. Keep it: it's how you know a delivery came from us.
Events
| Event | When | Scope it needs |
|---|---|---|
verification.status_changed | A verification's status changes (verified, on hold, withdrawn, terminated, transferred, canceled) | verification.read |
verification.expiring | A current verification is 30 days from its valid-to date | verification.read |
certificate.issued | A Certificate of Verification is issued for your TA's products | verification.read |
certificate.pdf_ready | A certificate PDF you asked for is ready | certificate.issue |
opportunity.shared | An opportunity is shared with your TA or with every TA | prospect.read |
opportunity.updated | An opportunity shared with you, or your own, changes stage or details | prospect.read |
client.application_reviewed | A client's application is decided | verification.read |
catalog.listing_changed | A product is newly listed, changed or delisted | catalog.read |
A TA's key gets its own TA's events only. GET /v2/event-types lists them too. Events are noticed within about a minute of the change reaching FIP-OS.
What a delivery looks like
POST /fip-webhooks HTTP/1.1
Content-Type: application/json
FIP-Signature: t=1794438000,v1=5f2c…
FIP-Event-Id: 01a1c2d3-…
FIP-Event-Type: verification.status_changed
FIP-Delivery-Id: 01a1c2d4-…
{"id": "01a1c2d3-…", "type": "verification.status_changed", "created_at": "2026-11-11T21:58:00Z", "environment": "production",
"delivery": "01a1c2d4-…", "attempt": 1,
"data": {"verification": "V-0123456", "program": "non_gmo", "from": "verified", "to": "on_hold", "effective_on": "2026-11-11", ...}}
The data is a summary: fetch the record (here GET /v2/verifications/V-0123456) for everything else.
Check the signature
FIP-Signature has a time t and one or more v1 values. Each v1 is the hex HMAC-SHA256 of <t>.<the raw body> with your secret. Accept the delivery when any v1 matches, compared in constant time, and t is within five minutes of now. Use the raw body exactly as received, before any JSON parsing.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(rawBody, header, secret) {
const parts = Object.groupBy(header.split(",").map((p) => p.split("=")), ([k]) => k)
const t = Number(parts.t?.[0]?.[1])
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
return (parts.v1 ?? []).some(([, v]) => { const got = Buffer.from(v, "hex"); return got.length === expected.length && timingSafeEqual(got, expected) })
}
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
items = [p.split("=", 1) for p in header.split(",")]
t = next((int(v) for k, v in items if k == "t"), 0)
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in items if k == "v1")
Answer, retries and replay
- Answer with any 2xx within 10 seconds. Do slow work after you answer.
- Anything else (an error, a timeout, a redirect) is tried again after 1, 5, 15 and 30 minutes, then up to every six hours, for 24 hours. Then the delivery gives up.
- Retries and replays send the same event
id: use it to skip one you've already handled. Deliveries can arrive out of order. - The delivery log (
GET /v2/webhooks/{id}/deliveries, or Keys and tokens) shows each attempt and the answer for 30 days. Send one again withPOST /v2/webhooks/{id}/deliveries/{delivery}/replay. POST /v2/webhooks/{id}/testsends awebhook.testevent now.
Rotating the secret
POST /v2/webhooks/{id}/rotate-secret gives a new secret. For the next 24 hours every delivery carries two v1 signatures, one with each, so switch when you're ready.
Sandbox
Webhooks made with a sandbox key (or under /sandbox/v2) get test and replayed deliveries only, since the sandbox's records don't change on their own yet.