Receive signed notifications when work finishes. Setup, events, signature checks, delivery logs, retries and polling recovery.
PCIFIC can POST a signed JSON event to your server when a result is saved. You can still poll every result as before, whether or not you use webhooks. Notifications contain identifiers and status, never the listing, review, document text or photos. Fetch the result using your own API key and the event's marketplaceId (shop id).
| Event | Resource | When it is saved |
|---|---|---|
orbit.draft_ready | orbit | A listing draft is saved. |
orbit.price_ready | orbit | The price check result is saved, including not available. |
orbit.safeguard_ready | orbit | The listing's Safeguard result is saved, including a failed review. |
orbit.finished | orbit | The listing finishes. |
orbit.failed | orbit | The listing fails, including a queue timeout. |
safeguard.review_finished | safeguard | A standalone or Orbit-linked review finishes with done or failed. |
pixeltrace.capture_ready | pixeltrace | The seller confirms the photo group on the camera page. |
pixeltrace.capture_completed | pixeltrace | The business publishes the confirmed photo group. |
ask.document_changed | document | New document content is saved as live or pending, or a waiting version is approved or rejected. |
webhook.test | test | You press Send test event. The resource id is the endpoint id. |
Orbit saves the draft before its optional price and Safeguard checks. Each part sends its own event when saved, and orbit.finished follows when processing ends. Events have no guaranteed delivery order. Each event describes the status at its save point; a later GET can show a newer status. All tools keep their existing versions and polling contracts.
{
"id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"type": "orbit.finished",
"createdAt": "2026-10-22T12:00:00Z",
"marketplaceId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"test": true,
"data": {
"id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"resource": "orbit",
"status": "listing"
}
}id is the stable event id, shared across retries and subscribed endpoints. data.id identifies the result: use GET /api/v1/listings/{id}, /api/v1/safeguard/reviews/{id}, /api/v1/pixel-trace/captures/{id}, or /api/v1/documents/{id}, with ?marketplaceId= from the envelope. test says which key environment started the work. For shared document changes it identifies the subscription environment. createdAt is when the event was written, not the time of its latest delivery attempt.
Each request includes PCIFIC-Event-Id and PCIFIC-Signature: t=<Unix seconds>,v1=<64 lowercase hex characters>. Verify HMAC-SHA256 over the decimal timestamp, a dot, and the untouched body bytes. Use a constant-time comparison and reject timestamps more than five minutes in the past or future. Keep the receiver's clock in sync. Each retry has a fresh timestamp and signature. An old captured request is rejected after five minutes; within that window deduplication by event id stops it being processed twice. Do not trust headers or parse or re-serialize the JSON before checking its signature.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPcificWebhook(rawBody, signature, secret, now = Date.now()) {
const match = /^t=([0-9]+),v1=([a-f0-9]{64})$/.exec(signature ?? "");
if (!match) throw new Error("Missing or invalid PCIFIC-Signature header");
const timestamp = Number(match[1]);
if (!Number.isSafeInteger(timestamp) || Math.abs(now / 1000 - timestamp) > 300) {
throw new Error("Webhook timestamp is outside the five minute window");
}
const expected = createHmac("sha256", secret)
.update(match[1] + ".").update(rawBody).digest();
const supplied = Buffer.from(match[2], "hex");
if (!timingSafeEqual(expected, supplied)) throw new Error("Invalid webhook signature");
return JSON.parse(rawBody.toString("utf8"));
}
// In your HTTP handler, collect the raw request bytes BEFORE parsing JSON:
// const event = verifyPcificWebhook(rawBody, req.headers["pcific-signature"],
// process.env.PCIFIC_WEBHOOK_SECRET);
// In one database transaction, insert event.id into a table with a UNIQUE key
// and enqueue your work only if that insert succeeds. A duplicate is a no-op.
// Return 204 after that transaction commits. Return 400 for a failed check.
// Keep accepted IDs while the resource remains available. Keep test-event
// IDs for at least 24 hours. Long outages can cause very late retries.
import hashlib
import hmac
import json
import re
import time
def verify_pcific_webhook(raw_body: bytes, signature: str, secret: str):
match = re.fullmatch(r"t=([0-9]+),v1=([a-f0-9]{64})", signature or "")
if not match:
raise ValueError("Missing or invalid PCIFIC-Signature header")
timestamp = int(match[1])
if abs(time.time() - timestamp) > 300:
raise ValueError("Webhook timestamp is outside the five minute window")
expected = hmac.new(secret.encode("utf-8"),
match[1].encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, match[2]):
raise ValueError("Invalid webhook signature")
return json.loads(raw_body)
# Read the untouched request bytes, verify, then deduplicate event["id"] in
# your database transaction before queuing work. Return 204 after committing.
# A duplicate also returns 204. A failed signature or timestamp returns 400.
PCIFIC writes the event and result in the same database transaction, then delivers in the background. Delivery is at least once: a receiver can accept an event just before a connection breaks, so the sender cannot know it succeeded. Save the event id and queue your work together before returning 2xx. Accept duplicates with 2xx, but do no work again. Each endpoint is retried independently.
A failed connection, timeout or non-2xx response is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, then every 6 hours. These are waits after the preceding attempt, and a busy worker can start later. Requests time out after 5 seconds. Responses larger than 16 KiB fail delivery. Redirects are never followed, including to another HTTPS address. The log explains failures and what to change. Fix the receiver and allow the next scheduled attempt to run.
Retries stop when the underlying result expires or is removed. Orbit and Safeguard currently keep results for 30 minutes after their last save, so a long retry wait may outlast that window. A ready PixelTrace group follows its capture expiry; a completed group has no time expiry. Document events have no time expiry while the document remains available. A queued test event expires after 24 hours. Delivery metadata is kept for 30 days after creation once no delivery is pending. Notifications add no result retention.
Keep polling as a recovery path after a lost notification. Use the result id from the original start response. Back off from a few seconds to longer waits while work continues and stop at a terminal result or expiry. If the result is gone, start new work only when your customer wants to retry. Adding a subscription does not replay earlier results. Removing an endpoint cancels its queued deliveries. Editing a URL sends future attempts to that URL.
Orbit: keep runId from POST /api/v1/listings. Read GET /api/v1/listings/{runId} every second while processing is true, even when status is listing. Show the draft as soon as item appears. Queueing, drafting and checks share a 3-minute processing ceiling; 30 seconds without progress stops the run. Seller-answer waiting is excluded, and retry cannot extend the ceiling. When Safeguard is on, wait for parts.safeguard passed and publishReady true before publishing, and fill required fields in pcific.unfilled. Stop polling when processing is false. On questions, pause the timer, show all questions to the seller, send their answers to POST /api/v1/listings/{runId}/answers and resume. Runs expire 30 minutes after their last change, including questions waiting for answers. A 404 NOT_FOUND means stop and offer to start again. Retry a failed run only after reading its error and with the customer's agreement.
Safeguard: keep reviewId from POST /api/v1/safeguard/reviews and read GET /api/v1/safeguard/reviews/{reviewId} every 3 to 5 seconds while working. Stop on done or failed. Read the error before offering POST /api/v1/safeguard/reviews/{reviewId}/retry. Stop on 404 and offer a fresh review. Let the seller continue while waiting.
PixelTrace: keep id, cameraUrl and expiresAt from POST /api/v1/pixel-trace/captures. Give the camera link to a person using iPhone Safari or Android Chrome. Read GET /api/v1/pixel-trace/captures/{id} in a background job, starting at 5 seconds and backing off to 10, 20 and at most 30 seconds while capturing. By default the link waits up to 15 minutes to be opened. Set openWithinMinutes to 1440 for 24 hours. After opening, the seller has 30 minutes to take and confirm photos. Settings can change these windows. Opening on a phone changes expiresAt; use the latest value. Opening on a computer does not use up the link. Stop waiting on ready and complete the group when the listing is ready to publish. Stop on completed, retracted, 404, or the latest expiresAt passing while capturing. Explain expiry and offer a new capture. Do not poll a public certificate for confirmation. Photo checks return their result in the same response.
Ask PCIFIC answers the buyer's question in the same response, as JSON or a stream with Accept: text/event-stream. There is no answer id to poll. Pass the stream through your server and stop when it closes. The document-change webhook reports saved policy content, separately from buyer answers.
For a polling GET returning 429, wait at least Retry-After seconds. For a temporary connection or 5xx failure, back off to 5, 10, 20 then 30 seconds with a small random delay. Keep the saved id and resume the same GET; reset the interval after success. Stop on other 4xx responses and follow tryThis. Never restart a paid POST just because polling failed. On work-starting calls, use an Idempotency-Key and reuse the same key and body if the response is lost.
Choose Rotate secret, save the newly shown secret and update your receiver. Future attempts use the new secret, including retries of queued events. A request already in flight may use the previous secret. If your receiver rejects it during the change, PCIFIC retries with the current secret. Send a test after updating your receiver. The previous secret cannot sign new attempts.
A test API key needs the webhooks permission to manage webhook setup. A test key can only act on test endpoints (test: true), including reads, delivery logs, secret rotation, test delivery and deletion. The list may include live metadata without secrets. Manage live endpoints on the dashboard. Live keys may read but cannot change it. The read permission also opens GET endpoints. Send ?marketplaceId= when your business has several shops. Store the URL exactly as you use it; surrounding spaces are refused rather than removed. Updating an endpoint requires its existing test value; create a new endpoint to change environments.
curl -X POST https://business.pcific.ai/api/v1/webhooks \
-H "Authorization: Bearer $PCIFIC_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/pcific/webhooks","test":true,"events":["orbit.finished","safeguard.review_finished","pixeltrace.capture_ready"]}'| Method and address | What it does |
|---|---|
GET /api/v1/webhooks | {endpoints,eventTypes}; never includes signing secrets. |
GET /api/v1/webhooks/{id} | One endpoint, with the same fields as an item in endpoints. Returns 404 for an unknown id or another business's endpoint. Never includes the signing secret. |
POST /api/v1/webhooks | {url,test,events} returns 201 {endpoint,secret}. Save secret once. |
PUT /api/v1/webhooks/{id} | {url,test,events} returns {endpoint}. Changes the URL and subscriptions, keeping its environment. |
DELETE /api/v1/webhooks/{id} | Returns 204. Cancels pending deliveries. |
POST /api/v1/webhooks/{id}/rotate-secret | Returns {secret}, shown only in this response. |
POST /api/v1/webhooks/{id}/test | Returns 202 {eventId,message}. Check the delivery log for its outcome. |
GET /api/v1/webhooks/{id}/deliveries | {deliveries}: newest 100 events, with eventId,eventType,createdAt,status,attempts,attemptedAt,responseCode,error,availableAt. status is waiting, sending, delivered or expired. error includes what to try next; availableAt is the next attempt time while waiting. |