Skip to content

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.

An endpoint subscribes to any of these event types:

TypeFires when
order.receivedAn order is recorded from an inbound message or file.
order.status_changedAn order changes status: processing, needs attention, ready, closed.
quote.createdA quote is created.
quote.updatedA quote's header, lines, discount table, or status changes.
quote.deletedA quote is deleted.
quote.presentedA Present version is saved: a link minted, or presented again.
quote.sharedA Present link is emailed to a customer.
quote.present_openedSomeone outside the workspace opens a Present.
quote.comment_createdA customer comments on a Present.
quote.share_revokedA Present link is revoked.
inbound.message_receivedAn inbound address receives an email.
embed.viewA single-item embed loads.
embed.selectionA visitor changes finish, size, or accessories in an embed.
embed.openA visitor opens an embed in Sunny.
usage.recordedA 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.

The body is the event envelope, verbatim. Parse it as JSON only after you have verified the signature against the raw bytes.

An order.received delivery
{
"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.

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.

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.

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:

Signature header
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:

Verify a delivery
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.

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.

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.

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.

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.
Rotate a secret, keeping the old one for 1 hour
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.