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

EventWhenScope it needs
verification.status_changedA verification's status changes (verified, on hold, withdrawn, terminated, transferred, canceled)verification.read
verification.expiringA current verification is 30 days from its valid-to dateverification.read
certificate.issuedA Certificate of Verification is issued for your TA's productsverification.read
certificate.pdf_readyA certificate PDF you asked for is readycertificate.issue
opportunity.sharedAn opportunity is shared with your TA or with every TAprospect.read
opportunity.updatedAn opportunity shared with you, or your own, changes stage or detailsprospect.read
client.application_reviewedA client's application is decidedverification.read
catalog.listing_changedA product is newly listed, changed or delistedcatalog.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 with POST /v2/webhooks/{id}/deliveries/{delivery}/replay.
  • POST /v2/webhooks/{id}/test sends a webhook.test event 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.