Webhooks
Sunny sends a signed HTTPS POST to your endpoint when something your workspace subscribes to
happens, such as an order arriving through order intake or a quote changing. Owners and admins
add endpoints under Organization → Webhooks, or through the API with a token carrying the
webhooks:manage scope.
Events
Section titled “Events”An endpoint subscribes to any of these event types:
| Type | Fires when |
|---|---|
order.received | An order is recorded from an inbound message or file. |
order.status_changed | An order changes status: processing, needs attention, ready, closed. |
quote.created | A quote is created. |
quote.updated | A quote's header, lines, discount table, or status changes. |
quote.deleted | A quote is deleted. |
quote.presented | A Present version is saved: a link minted, or presented again. |
quote.shared | A Present link is emailed to a customer. |
quote.present_opened | Someone outside the workspace opens a Present. |
quote.comment_created | A customer comments on a Present. |
quote.share_revoked | A Present link is revoked. |
inbound.message_received | An inbound address receives an email. |
embed.view | A single-item embed loads. |
embed.selection | A visitor changes finish, size, or accessories in an embed. |
embed.open | A visitor opens an embed in Sunny. |
usage.recorded | A billable AI call is recorded. |
Order events reach both sides of an order: the workspace that received it, and the workspace that sent it when Sunny can tell which one that is. The sender’s copy has an anonymous actor and leaves out the receiver’s internal reason codes.
Delivery body
Section titled “Delivery body”The body is the event envelope, verbatim. Parse it as JSON only after you have verified the signature against the raw bytes.
{ "v": 1, "id": "evt_...", "ts": 1755300000000, "type": "order.received", "orgId": "org_...", "actor": { "kind": "anonymous" }, "resource": { "type": "order", "id": "ord_..." }, "payload": { "orderId": "ord_...", "source": "email", "receiverOrgId": "org_...", "submitterOrgId": "org_..." }}orgId is the workspace the event belongs to. actor.kind is user (a signed-in person),
oauth (an OAuth token), api_key (an API key, by its id), or anonymous. No IP address,
user agent, or referrer is ever sent.
Order payloads
Section titled “Order payloads”Both order events carry:
| Field | Meaning |
|---|---|
orderId |
The order. |
receiverOrgId |
The workspace that received the order. |
submitterOrgId |
Optional. The workspace that sent the order, present only when Sunny can tell which one it was. |
source |
order.received only. How the order arrived: email, webhook (a file POSTed to an inbound webhook URL), portal, or api. |
order.status_changed also carries status, lineCount, issueCount, and sometimes a
reason code.
Headers
Section titled “Headers”| Header | Meaning |
|---|---|
X-Sunny-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256>, with a second v1 during a secret rotation. |
X-Sunny-Event-Id |
The envelope’s id. Your idempotency key. |
X-Sunny-Delivery-Id |
This delivery’s id. The same across its retries; a redelivery gets a new one. |
X-Sunny-Attempt |
1-based attempt number for this delivery. |
X-Sunny-Event-Type |
The envelope’s type, for routing without parsing the body. |
A test ping also carries X-Sunny-Test: 1 and an embed.view event.
Signature verification
Section titled “Signature verification”Each endpoint has a signing secret (whsec_…), shown once when you create the endpoint and
again when you rotate it. The signature is an HMAC-SHA256 of {t}.{raw body} keyed with that
secret:
X-Sunny-Signature: t=1755300000,v1=5f2b…The header can carry more than one v1. While a rotated secret’s grace window is open it
carries two, newest secret first. Accept the delivery when any v1 matches the digest you
compute; reading only the first one rejects every delivery for the length of the window if
your verifier still holds the old secret.
Reject a delivery whose t is more than 5 minutes from your clock, to guard against
replay. In Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySunnySignature(secret, rawBody, header, toleranceSeconds = 300) { let t; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.split("=", 2); if (key === "t") t = Number(value); if (key === "v1" && value) signatures.push(value); } if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); return signatures.some((hex) => { const given = Buffer.from(hex, "hex"); return given.length === want.length && timingSafeEqual(given, want); });}rawBody must be the exact bytes Sunny sent, not a re-serialized JSON object.
Retries and auto-disable
Section titled “Retries and auto-disable”Respond with a 2xx within 10 seconds. Anything else (another status, a network error, a timeout, or a redirect, which is never followed) is a failure and is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours. After the sixth attempt the delivery is marked failed.
Sunny also refuses to send to an address that resolves to a private network, or to one of
Sunny’s own domains, and counts that as a failure. Each failed delivery in the endpoint’s log
says why, such as HTTP 503, a timeout, or the reason it was refused.
An endpoint with 20 failed deliveries in a row is turned off, and the workspace’s owners and admins get an email. Events that happen while an endpoint is off aren’t queued for it. Turn it back on, then redeliver what you missed. A successful delivery resets the count.
Redelivery
Section titled “Redelivery”Any delivered or failed delivery in an endpoint’s log can be sent again, from Organization →
Webhooks or with POST /webhooks/:id/deliveries/:deliveryId/redeliver. A redelivery has the
same body and X-Sunny-Event-Id, a new X-Sunny-Delivery-Id, starts again at attempt 1, and
is signed with the endpoint’s current secret. The endpoint must be enabled, and a delivery of
the same event can’t still be retrying. Deliveries are kept for 30 days.
Idempotency
Section titled “Idempotency”Dedupe on X-Sunny-Event-Id, not the delivery id. Retries and redeliveries keep the event id,
and you can see an event again if your own handler failed after answering 2xx. Sunny never
fans the same event out to an endpoint twice on its own, but a redelivery sends it again on
purpose.
Managing endpoints from the API
Section titled “Managing endpoints from the API”Everything the Organization → Webhooks page does is available under /webhooks on the
REST API at https://api.sunnysdk.com. Send an OAuth token or a workspace API key carrying
the webhooks:manage scope as a bearer token (workspace API keys can carry this scope). See
the REST API reference for the full schemas and OAuth & scopes
for getting a token.
| Operation | What it does |
|---|---|
GET /webhooks, GET /webhooks/:id |
List endpoints (?includeArchived=true to include archived ones) or read one. |
POST /webhooks |
Create an endpoint with url, optional description, and eventTypes. The response includes the secret, once. |
PATCH /webhooks/:id |
Change the URL, description, or events. A new URL is checked the same way as a new endpoint. |
POST /webhooks/:id/test |
Send a signed test ping (an embed.view event) to the URL. Returns { ok, status, durationMs, error? }. |
POST /webhooks/:id/disable, POST /webhooks/:id/enable |
Pause or resume real deliveries. |
POST /webhooks/:id/rotate-secret |
Issue a new secret, returned once. Optional body { "graceSeconds": 86400 }: 0 to 604800 (7 days), default 24 hours. |
GET /webhooks/:id/deliveries |
The endpoint’s delivery log. |
POST /webhooks/:id/deliveries/:deliveryId/redeliver |
Send a delivery again. |
POST /webhooks/:id/archive |
Retire an endpoint for good. |
curl -X POST https://api.sunnysdk.com/webhooks/$ENDPOINT_ID/rotate-secret \ -H "Authorization: Bearer $SUNNY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "graceSeconds": 3600 }'The response holds the new secret and previousSecretExpiresAt, when the old secret stops
signing (null with graceSeconds: 0). During the window every delivery carries two v1
signatures. Use graceSeconds: 0 instead when a secret has leaked.
A workspace can have up to 10 endpoints, disabled ones included. To make room, archive one you no longer need: it stops receiving events for good and can’t be turned back on, but its delivery log stays readable for 30 days. Test, enable, disable, and rotate on an archived endpoint return 409.