Verifi API v1
Connect your system to Verifi
Push shipments and documents from your ERP or accounting system, run Verifi's pre- and post-shipment checks, and receive the results by webhook. The API works on your organisation's own shipments only. It cannot sign, publish or file anything: reviewer sign-off stays in Verifi, and Verifi never files with customs or GST.
Quickstart: your first shipment in four calls
- An owner or admin opens Settings → Developers and creates a test key. That also creates your sandbox: a separate organisation with three sample shipments.
- Check the key:
curl -s https://<api-host>/api/public/v1/me \ -H "Authorization: Bearer vk_test_…" - Create a shipment. Send an
Idempotency-Keyso a retry never makes a second one:curl -s -X POST https://<api-host>/api/public/v1/shipments \ -H "Authorization: Bearer vk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c2a1e-erp-row-42" \ -d '{"reference":"INV-2026-0042","hs_code":"09041130","destination":"AE", "incoterm":"FOB","currency":"USD","invoice_value":18500, "commercial_invoice_no":"INV-2026-0042","commercial_invoice_date":"2026-10-01"}' - Run the pre-shipment check and read the findings. Each finding has a
fixand anowner(who acts next: you, your CHA, the bank…):curl -s -X POST https://<api-host>/api/public/v1/shipments/<id>/checks \ -H "Authorization: Bearer vk_test_…" -H "Content-Type: application/json" \ -d '{"stage":"PRE"}'
Upload documents with POST /shipments/<id>/documents as multipart/form-data (a file part and a doc_type field such as COMMERCIAL_INVOICE). Files are PDF, JPEG or PNG under 10 MB and are malware-scanned before they are stored.
Recording an LEO or bill of lading while the pre-shipment check is not run or has open problems needs a person's confirmation in Verifi; the API refuses ship_confirmation. The sandbox holds up to 25 shipments and runs up to 50 checks a day.
Find a shipment by your own number with GET /shipments?reference=INV-2026-0042. Send later facts (shipping bill, LEO, B/L, realisation) with PATCH /shipments/<id>: only what changed; null clears a field.
Keys and the sandbox
- Live keys (
vk_live_…) act on your real shipment register. Test keys (vk_test_…) act on your sandbox, which is never billed and never counted in your figures. Every response sayslivemode. - Keys are read-only or read-and-write. Send them as
Authorization: Bearer …from your server only, never from a browser or a mobile app. - A key acts for your organisation through the owner or admin who created it, so every change is attributed to a named person. If that person leaves or stops being an admin, the key stops (
API_KEY_OWNER_REMOVED): create a new one. - Verifi stores only a fingerprint of each key. A lost key cannot be shown again; revoke it and create another.
Retries and Idempotency-Key
Networks fail. Send a unique Idempotency-Key (a UUID, or your ERP row id) on every create: shipments, documents and checks. A retry with the same key and the same body returns the first result with the header Idempotent-Replayed: true. The same key with a different body is refused (IDEMPOTENCY_KEY_REUSED). Keys are kept for 24 hours. A request that failed creates nothing, so you can fix it and retry with the same key.
Retry on 429 and 5xx with exponential backoff. Do not retry other 4xx answers unchanged: they say what to fix.
Webhooks: results without polling
Add an HTTPS endpoint on a public host, port 443 or 8443, in Settings → Developers. Private, local and government addresses are refused, and every send is checked again. (On a Verifi instance you run yourself for development, PUBLIC_API_ALLOW_LOCAL_WEBHOOKS=true also allows http://localhost.) Verifi sends a POST for each event:
| Event | When |
|---|---|
shipment.created | A shipment is created, from your system, the web app or a spreadsheet import. |
shipment.updated | Any other change: details edited, a step done, assigned, closed, the CHA's entries. |
shipment.document_added | A document is uploaded or generated on the shipment. |
shipment.check_completed | A pre- or post-shipment check finished. Carries the blocker and warning counts. |
{
"id": "0b6c…", // de-duplicate on this
"object": "event",
"type": "shipment.check_completed",
"created_at": "2026-10-03T09:12:44.120Z",
"livemode": true,
"data": { "shipment_id": "…", "reference": "INV-2026-0042", "activity_kind": "CHECK_RUN",
"check": { "id": "…", "stage": "PRE", "blockers": 1, "warnings": 2, "result_hash": "…" } }
}Events carry ids and counts only, never names or free text. Read the shipment for the rest. Answer any 2xx within 10 seconds; anything else is retried after 1 min, 5 min, 30 min, 2 h, 5 h, 10 h and 24 h. After 50 failures in a row the endpoint is switched off and the owner sees it in Settings. Missed events are always available from GET /events.
Check the signature
Each delivery has Verifi-Signature: t=<unix seconds>,v1=<hex>, an HMAC-SHA256 of t + "." + raw body with your endpoint's signing secret. Reject a signature older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (before JSON parsing).
export function verifyVerifiSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}import hmac, hashlib, time
def verify_verifi_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
return False # a malformed header is a failed check, never an exception
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)Errors
Every error has the same shape. code never changes meaning; param names the field at fault; request_id is also the x-request-id header: quote it to support.
{ "error": { "type": "invalid_request_error", "code": "VALIDATION_FAILED",
"message": "Use the 2-letter country code.", "param": "destination",
"doc_url": "https://…/developers#error-validation_failed", "request_id": "4bf9…" } }Loading the error codes…
Limits and versions
- 100 requests a minute per key. Over that you get
429withRetry-After. For a large back-load, use the spreadsheet import in Verifi. - Lists are newest first, 25 a page (up to 100), with
starting_afterandhas_more. - v1 changes only by adding: new fields, new event types, new error codes. A breaking change comes as v2, announced in advance.
- Check runs are deterministic: the same facts and ruleset give the same
result_hash, so a result can be replayed later.
ERP recipes
- Tally: on saving an export sales voucher, POST the shipment with the voucher number as
referenceand as the Idempotency-Key; attach the invoice PDF. A native Tally connector is on the roadmap. - Zoho Books: a Zoho workflow webhook on invoice approval calls your small relay, which posts to Verifi with your key (keep the key out of Zoho itself).
- SAP or Oracle: a nightly job reads new billing documents and posts them; a webhook receiver writes check results back as a status on the delivery. A file bridge (CSV over SFTP) is on the roadmap.
- Banks and CHAs working for an exporter: the exporter creates the key for you; it only reaches that exporter's shipments.