Webhooks
Webhooks push events to your server as they happen, so you do not have to poll for changes. Register an HTTPS endpoint, subscribe it to the events you care about, and Glance will POST a signed JSON payload to it every time one occurs.
Registering an Endpoint
Endpoints are managed under /webhooks. The same routes are also served under /grow/webhooks, which is a permanent alias kept for existing integrations.
POST /webhooks
Content-Type: application/json
{
"url": "https://billing.example.com/hooks/glance",
"events": ["document.created", "document.status_changed"]
}The response returns the signing secret. It is shown once, at creation — store it immediately; it is never returned again by any endpoint.
{
"success": true,
"id": "we_4f1c9a2e-0b6d-4f0a-9d5e-2c8b7a1f3e64",
"url": "https://billing.example.com/hooks/glance",
"events": [
"document.created",
"document.status_changed"
],
"secret": "whsec_2f9c1d4e7a0b3c6d9e2f5a8b1c4d7e0f3a6b9c2d5e8f1a4b"
}GET /webhooks lists your endpoints (without their secrets), and DELETE /webhooks/:id removes one. Managing endpoints requires an owner-level key.
"events": ["*"] to receive every event type, including ones added after you registered.Event Types
| Event | Sent when |
|---|---|
document.created | A document is issued — manually, through the API, or by the recurring-billing cron. |
document.status_changed | A document's settlement changed: it was closed manually, covered by a new document, or settled by a receipt allocation. |
document.allocated | A receipt was allocated against one or more invoices. |
document.cancelled | A receipt, credit note or supplier invoice was cancelled. |
document.refunded | A credit note (REFUND / RETURN) was issued against this document. |
document.approval_number.assigned | A Tax Authority allocation number was recorded on the document — including one completed after issue, when the document was first returned as PENDING. |
charge.succeeded | A card charge was approved. |
charge.failed | A card charge was declined. |
charge.captured | A previously authorized charge was captured. |
charge.canceled | An authorization was voided. |
refund.created | A refund was issued against a charge. |
payment_method.attached | A card was saved for a customer. |
payment_method.detached | A saved card was removed. |
Payload
Every delivery has the same envelope. id identifies the event (shared across all endpoints it fans out to), created is a Unix timestamp in seconds, and data holds the event-specific body.
{
"id": "evt_7b1e4c9a-2d3f-4a5b-8c6d-9e0f1a2b3c4d",
"type": "document.status_changed",
"created": 1774428000,
"data": {
"document": {
"id": "d0f3a1c7-5b2e-4d8a-9f6c-1e3b5d7a9c2f",
"documentId": 84213,
"type": "INVOICE",
"number": 1042,
"externalId": "erp-9912",
"entityId": 5531,
"retainerId": null,
"issueDate": "2026-08-18T09:00:00.000Z",
"updatedAt": "2026-08-18T10:30:00.000Z",
"currency": "ILS",
"amount": 1000,
"tax": 180,
"totalWithTax": 1180,
"status": "PARTIALLY_PAID",
"isOpen": true,
"remainingAmount": 180,
"coveredAmount": 1000,
"taxAuthorityApprovalNumber": "31530001234567890",
"approvalNumberStatus": null,
"cancelled": false
},
"reason": "ALLOCATION",
"changedBy": "a91c5e2d-7f04-4b6a-8c3d-2e5f7a9b1c40"
}
}Document events always carry the document under data.document, with the fields above. id is the public document id used across the API and documentId is the internal numeric id that endpoints such as receipt allocation expect. externalId is the id you supplied when creating the document, so you can match the event to your own record without a lookup. Some events add fields next to document: allocations on document.allocated, cancellationReason on document.cancelled, refundedBy on document.refunded.
GET /documents/:id with your API key when you need the full document.Verifying the Signature
Each request carries a Glance-Signature header:
Glance-Signature: t=1774428000,v1=5f8a...c31d
v1 is the HMAC-SHA256 of ${t}.${rawBody}, keyed with your endpoint secret. Compute it over the raw request body — re-serializing the parsed JSON changes the bytes and the signature will not match.
import crypto from "crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=")),
);
const timestamp = Number(parts.t);
// Reject replays of an old, validly-signed delivery.
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
}crypto.timingSafeEqual, not ===.Delivery & Retries
Respond with any 2xx status to acknowledge a delivery. Anything else — including a timeout after 10 seconds — is treated as a failure and retried on this schedule:
| Retry | Delay after the previous attempt |
|---|---|
| 1st | 1 minute |
| 2nd | 5 minutes |
| 3rd | 30 minutes |
| 4th | 2 hours |
| 5th | 12 hours |
After the initial send and five retries the event is dropped. Each endpoint is retried independently: one failing endpoint never delays or drops deliveries to another.
200 as soon as you have stored the event and do the work asynchronously. Slow handlers are indistinguishable from failures once they cross the 10-second timeout, and will be redelivered.Idempotency on Your Side
Delivery is at least once. A handler that times out after doing its work, or a network failure on the response, both result in the same event arriving again — so your endpoint must be safe to call twice with the same body.
Deduplicate on the envelope's id: it is stable across every retry of the same event. Record processed ids and ignore one you have already seen.
document.created can arrive after the document.status_changed that followed it. Treat each payload as the state at created, and ignore an event older than what you have already recorded for that document.