Skip to main content

Webhooks

Webhooks let ARC tell your system when something changes in your workspace, so you do not have to poll. When an event happens, ARC sends an HTTPS POST with a signed JSON notification to every active endpoint subscribed to that event type.

Notifications are thin. They carry ids, a document number, a status, a total and a human-readable text. They do not carry the full record. When you need the document, fetch it by id through the GraphQL reference.

Delivery is best-effort and unordered. A notification can arrive twice, out of order, late, or, rarely, not at all. ARC sends it after the business change has been saved. A slow or failing endpoint never holds that change back, and a failed notification does not roll it back. Design your receiver to reconcile against GraphQL (see Build a reliable receiver).

One workspace has one set of endpoints. The organization comes from the credential used to create them, so you never pass an organization id. Each endpoint gets its own delivery for each matching event. Two endpoints subscribed to the same event receive two deliveries with different delivery ids.

business action → event → signed POST to your endpoint → your 2xx
Legacy single-URL channel

Workspaces may also have a separate Settings → Notifications → Webhook Notifications setting with one URL. That channel is not signed, is not retried and has no delivery log. While it is on, every event is also sent there. Once you use webhook endpoints, turn the legacy channel off.

Quick start​

  1. Build a receiver that verifies the signature. See Verify the signature.
  2. Create an endpoint with createWebhookEndpoint.
  3. Store the secret it returns. It is shown once.
  4. Send a test event with sendWebhookTestEvent.
  5. Check the delivery log with webhookDeliveries.

Console users can do the same at Settings → Webhooks (/settings/webhooks).

These are GraphQL operations that need a user token. Get the token as described in Authentication. The organization is taken from the token, never from an argument. Grant the settings:webhooks:read, settings:webhooks:create, settings:webhooks:update and settings:webhooks:delete permissions, as needed, to the role of the person who manages webhooks. See Tenancy and permissions.

  • webhookEventTypes: [String!]! Requires settings:webhooks:read.
  • createWebhookEndpoint(input: WebhookEndpointInput!): WebhookEndpointWithSecret! Requires settings:webhooks:create.
  • sendWebhookTestEvent(endpointId: ID!): WebhookDelivery! Requires settings:webhooks:update.
  • webhookDeliveries(filter: WebhookDeliveryFilterInput): [WebhookDelivery!]! Requires settings:webhooks:read.

webhookEventTypes returns the event types you can subscribe to. See the event catalog.

Endpoint input​

WebhookEndpointInput has these fields:

FieldRequiredRules
nameYesUp to 120 characters. A blank name is refused.
urlYesUp to 2048 characters. See the URL rules below.
eventTypesYesAt least one event type, at most 50 entries. Duplicates are removed. Each must appear in webhookEventTypes, or it is refused with Unknown event type: <type>.
isActiveNoDefaults to true when you create an endpoint.

The url must be a publicly reachable HTTPS URL. ARC refuses these:

  • a URL that cannot be parsed, or that is not http or https;
  • http:// on the production service (Only https URLs are allowed);
  • a URL that contains a username or password;
  • a host that is a private, loopback or link-local address, or a cloud-metadata address, whether written as an IP literal or resolved from a name;
  • a host name that cannot be resolved.

Outside production, http:// URLs to public hosts are accepted. For local development, a public HTTPS tunnel to your machine works in every environment.

Create an endpoint​

query EventTypes {
webhookEventTypes
}

mutation CreateEndpoint($input: WebhookEndpointInput!) {
createWebhookEndpoint(input: $input) {
secret # shown once - store it now
endpoint {
id
name
url
eventTypes
isActive
secretHint
createdAt
}
}
}

Variables:

{
"input": {
"name": "Order sync",
"url": "https://integrations.example.com/arc/webhooks",
"eventTypes": ["order.created", "order.status_changed", "invoice.paid"]
}
}

The secret has the form whsec_ followed by 43 URL-safe base64 characters. createWebhookEndpoint and rotateWebhookEndpointSecret return it in plain text, and only then. Reads return secretHint, the last four characters, and nothing more. If the secret is lost, rotate it (see Manage endpoints).

If create or rotate fails with an error saying that webhook signing is not configured on this server, the deployment is missing its signing key. Contact your Growmax account manager.

Send a test event​

Requires settings:webhooks:update.

mutation Test($endpointId: ID!) {
sendWebhookTestEvent(endpointId: $endpointId) {
id
eventType
status
attempts
}
}

query Deliveries($filter: WebhookDeliveryFilterInput) {
webhookDeliveries(filter: $filter) {
id
endpointId
eventType
status
attempts
responseStatus
lastError
lastAttemptAt
deliveredAt
createdAt
}
}

Variables:

{
"filter": { "endpointId": "<endpoint-id>", "limit": 20 }
}

sendWebhookTestEvent works even when the endpoint is disabled, but not for a deleted endpoint. It returns the delivery in PENDING state straight away, not your receiver's answer. Poll webhookDeliveries to see the result.

The request ARC sends​

ARC sends a POST to your URL. The body is the stored JSON payload, serialised compactly and encoded as UTF-8. Each request carries these headers:

  • Content-Type: application/json; charset=UTF-8
  • User-Agent: ARC-Webhooks/1
  • X-ARC-Timestamp: Unix time in seconds, as an integer string.
  • X-ARC-Signature: v1= followed by 64 lowercase hex characters.
  • X-ARC-Delivery: the delivery id. It is the same on every retry and replay.
  • X-ARC-Event: the public event type, such as invoice.paid.
POST /arc/webhooks HTTP/1.1
Content-Type: application/json; charset=UTF-8
User-Agent: ARC-Webhooks/1
X-ARC-Timestamp: 1700000000
X-ARC-Signature: v1=<64 hex chars>
X-ARC-Delivery: <delivery-id>
X-ARC-Event: invoice.paid

{"id":"<delivery-id>","event":"invoice.paid","type":"invoice.paid","organizationId":"<organization-id>","occurredAt":"2023-11-14T22:13:20.000Z","text":"Invoice <invoice-number> fully paid","data":{"invoiceId":"<invoice-id>","invoiceNumber":"<invoice-number>","total":1250.5,"currency":"<ISO-4217 code>"}}

The sample shortens text. A real text starts with an emoji and, for documents with a total, includes a formatted amount.

Every body uses the same envelope. Only data and text change between event types.

  • id: the delivery id. It equals X-ARC-Delivery, and it stays the same across retries and replays. Use it to deduplicate.
  • type: the public event type you subscribed to. It equals X-ARC-Event. Branch on this field.
  • event: ARC's internal event name. It is informational. Do not branch on it, because it may change.
  • organizationId: the workspace that owns the endpoint.
  • occurredAt: when the event was recorded, in ISO-8601 UTC. It is set once and does not change on retry or replay.
  • text: a human-readable one-line summary for chat tools. Its format is not a contract. Never parse it.
  • data: the fields for this event type. See the event catalog.

Key order inside the JSON is not part of any contract. Always verify the raw bytes, never a re-serialised copy.

Your endpoint answers with any 2xx status to mark the delivery as delivered. The response body is ignored. ARC allows 10 seconds per attempt, counting DNS, connection, TLS and response headers. A timeout counts as a failed attempt and is retried. ARC does not follow redirects. A 3xx response counts as a failed attempt and is retried, so register the final HTTPS URL.

Verify the signature​

Verify every request before you parse it. The signature is the only thing that proves the request came from ARC.

  1. Read the X-ARC-Timestamp header exactly as sent.
  2. Build the signed string: the timestamp, a literal dot, then the raw request body bytes.
  3. Compute HMAC-SHA256 over the signed string, using the entire secret as the key.
  4. Compare v1= followed by the lowercase hex digest with the X-ARC-Signature header, in constant time.

The rules behind those steps:

  • The key is the whole secret. Use the full whsec_... string as UTF-8 text. Do not strip the prefix and do not base64-decode it.
  • There is exactly one signature. The header holds one v1= value. There is no list of signatures and no overlap window during rotation.
  • Every attempt is signed afresh. Each attempt gets a new timestamp and therefore a new signature. The body and X-ARC-Delivery stay the same. Do not use the signature or the timestamp as a dedupe key.
  • ARC recommends a 300-second timestamp window. Reject requests whose timestamp is more than 300 seconds away from your clock. ARC does not enforce a window. Retries carry a new timestamp, so they are not rejected for age, which means only your clock skew matters. Keep your server clock synchronised with NTP.
  • Verify the raw bytes. Check the signature before JSON parsing. A body parser that re-serialises the JSON breaks the signature. In Express, use express.raw({ type: "application/json" }). In Flask, use request.get_data().
  • Reject bad signatures with 401. Any 4xx other than 408, 425 and 429 is a terminal failure, so a wrong secret fails the delivery permanently. After you fix the secret, replay the failed deliveries (see Delivery log, retries and replay).

Node.js receiver​

import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ARC_WEBHOOK_SECRET; // the whole whsec_... string
const TOLERANCE_SECONDS = 300;

function verify(secret, timestamp, rawBody, signatureHeader) {
const ts = Number(timestamp);
if (!Number.isInteger(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;
const expected = "v1=" + createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}

createServer((req, res) => {
if (req.method !== "POST") { res.writeHead(405).end(); return; }
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks); // RAW bytes, before any JSON.parse
if (!verify(SECRET, req.headers["x-arc-timestamp"], raw, req.headers["x-arc-signature"])) {
res.writeHead(401).end(); return;
}
const event = JSON.parse(raw.toString("utf8"));
// 1. durably record event.id (= X-ARC-Delivery); ignore if already seen
// 2. enqueue your own processing
res.writeHead(200).end(); // answer fast (< 10 s)
});
}).listen(3000);

Python receiver​

import hashlib, hmac, os, time

SECRET = os.environ["ARC_WEBHOOK_SECRET"] # the whole whsec_... string
TOLERANCE_SECONDS = 300

def verify(secret: str, timestamp: str, raw_body: bytes, signature_header: str) -> bool:
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > TOLERANCE_SECONDS:
return False
digest = hmac.new(
secret.encode("utf-8"),
timestamp.encode("utf-8") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
expected = "v1=" + digest
return hmac.compare_digest(expected.encode("utf-8"), (signature_header or "").encode("utf-8"))

A minimal Flask route that uses it:

from flask import Flask, request

app = Flask(__name__)

@app.post("/arc/webhooks")
def arc_webhooks():
raw = request.get_data() # raw bytes, before any JSON parsing
ok = verify(SECRET, request.headers.get("X-ARC-Timestamp", ""), raw,
request.headers.get("X-ARC-Signature", ""))
if not ok:
return ("", 401)
# 1. durably record the X-ARC-Delivery id; ignore if already seen
# 2. enqueue your own processing
return ("", 200)

The Flask route is a thin wrapper around verify(). Test verify() itself.

To test your verifier before you deploy it, sign a body you control with the same construction: HMAC-SHA256 over the timestamp, a dot and the body, keyed with a test secret of the form whsec_ followed by 43 characters. Use the current Unix time as the timestamp. Your verifier should accept the request. Then change one byte of the body and confirm that it is rejected.

Event catalog​

webhookEventTypes returns these seven types. You can subscribe an endpoint to any of them.

TypeFires whendata keys
order.createdA sales order is created through createSalesOrder, quote conversion, buyer checkout or an approved buyer order. It does not currently fire for orders created by duplicateSalesOrder or bulkImportSalesOrders, or synced in from an external integration, so do not assume every order produces this event.orderId, orderNumber, total, currency, status
order.status_changedThe status of a sales order changes through confirmation, a staff status change, shipping, delivery, short-close, cancellation, invoicing, invoice cancellation or a return. A status can move backwards, for example from INVOICED to CONFIRMED when an invoice is cancelled.orderId, orderNumber, newStatus
order.payment_confirmedAn online payment-gateway checkout for the order succeeds and the provider confirms it. A payment recorded by hand does not fire this event.orderId, orderNumber
invoice.generatedA sales invoice is created with createInvoice or by converting a quote to an invoice. Purchase invoices never fire it. It does not currently fire for invoices created by duplicateInvoice or bulkImportInvoices, or synced in from an external integration.invoiceId, invoiceNumber, total, currency
invoice.paidA sales invoice becomes fully paid, through a payment allocation or a credit note. Payments against purchase invoices do not fire it.invoiceId, invoiceNumber, total, currency
quote.acceptedA buyer accepts a quote from a buyer-facing surface, such as the customer portal. Staff marking a quote as accepted in the console does not fire it.quoteId, quoteNumber, partnerId, total, currency
inventory.adjustmentA manual stock adjustment or an applied stock count. There is one delivery per stock movement. Repeating an adjustment with the same idempotency key does not fire it again. Sales, purchase, transfer and return movements do not fire it.movementId, productId, quantity

Notes on the fields:

  • total is the document's total amount as a number, in the document's currency. currency is that currency's ISO code. For authoritative amounts, re-read the document. See Money, pricing, and tax.
  • partnerId can be null.
  • quantity is signed. A negative value is a decrease. For stock-count lines, productId can be absent. The payload has no warehouse or variant id. Look up the movement by movementId through GraphQL.
  • order.status_changed carries only the new status. It has no previous status, no reason and no tracking details. If you need the previous state, re-read the order. Do not assume status changes only move forward. For what each status means, see Build a quote-to-cash integration.

The samples below show type and data. The rest of the envelope is as shown in The request ARC sends.

order.created​

{
"type": "order.created",
"data": {
"orderId": "<order-id>",
"orderNumber": "<order-number>",
"total": 1250.5,
"currency": "<ISO-4217 code>",
"status": "DRAFT"
}
}

order.status_changed​

{
"type": "order.status_changed",
"data": {
"orderId": "<order-id>",
"orderNumber": "<order-number>",
"newStatus": "CONFIRMED"
}
}

order.payment_confirmed​

{
"type": "order.payment_confirmed",
"data": {
"orderId": "<order-id>",
"orderNumber": "<order-number>"
}
}

invoice.generated​

{
"type": "invoice.generated",
"data": {
"invoiceId": "<invoice-id>",
"invoiceNumber": "<invoice-number>",
"total": 1250.5,
"currency": "<ISO-4217 code>"
}
}

invoice.paid​

{
"type": "invoice.paid",
"data": {
"invoiceId": "<invoice-id>",
"invoiceNumber": "<invoice-number>",
"total": 1250.5,
"currency": "<ISO-4217 code>"
}
}

quote.accepted​

{
"type": "quote.accepted",
"data": {
"quoteId": "<quote-id>",
"quoteNumber": "<quote-number>",
"partnerId": "<partner-id>",
"total": 1250.5,
"currency": "<ISO-4217 code>"
}
}

inventory.adjustment​

{
"type": "inventory.adjustment",
"data": {
"movementId": "<movement-id>",
"productId": "<product-id>",
"quantity": -3
}
}

webhook.test​

This event is sent only by sendWebhookTestEvent. You cannot subscribe an endpoint to it. Its event and type are both webhook.test, and its text is Test delivery from ARC.

{
"type": "webhook.test",
"data": { "test": true }
}

Shipment events are not available to webhook endpoints today.

Delivery log, retries and replay​

Every delivery is recorded. Use webhookDeliveries to read the log and webhookDeliveriesCount to count it. Both require settings:webhooks:read. webhookDeliveries returns the newest delivery first.

Each WebhookDelivery has these fields:

  • id, endpointId, eventType
  • status: PENDING, SUCCEEDED or FAILED
  • attempts: how many attempts have been made
  • responseStatus: the HTTP status of the last attempt, or null
  • lastError: a message of up to 500 characters, such as Receiver responded with HTTP 503 or No response within 10000ms
  • lastAttemptAt, deliveredAt, createdAt

The response body and the payload are not exposed through the API.

The filter takes endpointId, status (PENDING, SUCCEEDED or FAILED), limit (1 to 200, default 50) and offset (0 or more). Use webhookDeliveriesCount with the same filter to get the total for paging.

  • PENDING means queued or waiting for a retry. A delivery with attempts above zero has failed at least once.
  • SUCCEEDED means your endpoint returned a 2xx.
  • FAILED means the retries ran out, or your endpoint gave a terminal response.

Retry policy​

The retry policy is fixed. It is not set per endpoint or per workspace.

  • ARC makes 6 attempts in total: one initial attempt and five retries.
  • ARC retries network errors, DNS failures, timeouts, 5xx responses, 3xx responses, 408, 425 and 429.
  • ARC does not retry any other 4xx response, such as 400, 401, 403, 404, 410 or 422. Those deliveries become FAILED straight away. The same happens when ARC's safety check refuses the URL at send time, or when the endpoint was deleted or disabled after the event was queued.
  • ARC does not read the Retry-After header.

The waits between attempts are:

After attemptWait before the next attempt
1 fails5 seconds
2 fails15 seconds
3 fails35 seconds
4 fails75 seconds
5 fails155 seconds
6 failsThe delivery becomes FAILED

A failed delivery therefore becomes FAILED about five minutes after its first attempt, more if attempts time out.

ARC limits how many attempts it sends to one endpoint at the same time, so a burst of events can be spread out over a short period. A delivery held back by that limit normally waits without using up an attempt. There is no ordering between deliveries.

Replay​

replayWebhookDelivery(id: ID!): WebhookDelivery! requires settings:webhooks:update. It sends a FAILED or SUCCEEDED delivery again, starting from attempt zero. It sends the stored payload, so the id, occurredAt and data stay the same. The timestamp and signature are new. The status, attempts, error, response status and delivered time are reset.

Replay is refused in these cases:

  • an unknown id: Webhook delivery not found;
  • a delivery that is still PENDING: This delivery is still queued — wait for it to finish before replaying it;
  • a delivery that another replay request has just reset: This delivery is already being replayed.

ARC accepts a replay even when the endpoint is deleted or disabled. The delivery then becomes FAILED again with Endpoint is deleted or disabled. For a disabled endpoint, re-enable it first with updateWebhookEndpoint and isActive: true, then replay. A deleted endpoint cannot be re-enabled.

Replaying a SUCCEEDED delivery sends a duplicate. Your receiver must deduplicate on id.

Delivery records are deleted by a daily clean-up once they are 30 days old. Do not count on replaying a delivery older than 30 days. Records of deleted endpoints are kept until the same limit.

ARC does not automatically disable failing endpoints. Monitor FAILED deliveries. A failing endpoint keeps receiving new events, and each of those fails after about five minutes, until an administrator disables it.

query FailedDeliveries($endpointId: String) {
webhookDeliveries(filter: { endpointId: $endpointId, status: "FAILED", limit: 50 }) {
id
eventType
attempts
responseStatus
lastError
lastAttemptAt
createdAt
}
webhookDeliveriesCount(filter: { endpointId: $endpointId, status: "FAILED" })
}

mutation Replay($id: ID!) {
replayWebhookDelivery(id: $id) {
id
status
attempts
}
}

Manage endpoints​

  • webhookEndpoints: [WebhookEndpoint!]! lists your endpoints, newest first, and leaves out deleted ones. Requires settings:webhooks:read.
  • updateWebhookEndpoint(id: ID!, input: WebhookEndpointInput!): WebhookEndpoint! requires settings:webhooks:update.
  • rotateWebhookEndpointSecret(id: ID!): WebhookEndpointWithSecret! requires settings:webhooks:update.
  • deleteWebhookEndpoint(id: ID!): Boolean! requires settings:webhooks:delete.

updateWebhookEndpoint replaces name, url and eventTypes in full. The URL checks run again. isActive is optional. Leave it out to keep the current value. To pause an endpoint, send its current name, url and eventTypes with isActive: false.

A disabled endpoint receives no new events. Deliveries already queued for it fail with Endpoint is deleted or disabled. ARC does not back-fill events that happened while an endpoint was disabled, so reconcile from GraphQL after you re-enable it.

A change to eventTypes applies to events that happen after the update.

Rotating the secret returns the new secret once. The old secret stops verifying immediately, and there is no overlap period. Every later attempt, including retries of deliveries already queued, is signed with the new secret. A safe order of steps:

  1. Call rotateWebhookEndpointSecret and copy the new secret.
  2. Store the new secret in your receiver straight away. Until you do, deliveries fail verification and become FAILED.
  3. Replay the FAILED deliveries from the gap.

If you cannot accept a short gap, have your receiver accept either the old or the new secret during the switch. ARC still signs with only one secret at a time. This is a receiver-side measure only.

Deleting an endpoint is a soft delete. It disappears from webhookEndpoints, stops receiving events, and its secret is no longer used. Its delivery log stays visible until the retention period ends.

Create, update, rotate, delete, test and replay actions are recorded in the workspace audit log. The signing secret is never recorded.

mutation Pause($id: ID!, $input: WebhookEndpointInput!) {
updateWebhookEndpoint(id: $id, input: $input) {
id
isActive
eventTypes
updatedAt
}
}

mutation Rotate($id: ID!) {
rotateWebhookEndpointSecret(id: $id) {
secret
endpoint { id secretHint updatedAt }
}
}

Variables for Pause:

{
"id": "<endpoint-id>",
"input": {
"name": "Order sync",
"url": "https://integrations.example.com/arc/webhooks",
"eventTypes": ["order.created"],
"isActive": false
}
}

Build a reliable receiver​

Use this checklist for every endpoint.

  1. Verify the signature against the raw body before you parse it.
  2. Store the id (X-ARC-Delivery) durably. Keep ids for at least 30 days, the retention period, so that replays can be deduplicated.
  3. Respond with a 2xx within a few seconds. The hard limit is 10 seconds.
  4. Process the event asynchronously, after you have answered.
  5. Treat data as a summary. Fetch the current document by id through GraphQL before you change any state.
  6. Expect events to arrive out of order, twice, or not at all. Treat occurredAt as a hint only, and re-read the state rather than trusting arrival order.
  7. Run a nightly reconciliation job that pages through the GraphQL lists. Events that happened while an endpoint was disabled, and events lost to an outage longer than the retry window, are not sent again automatically. For the reconciliation steps, see Events and webhooks. For paging and retry rules, see Errors, pagination, and idempotency.

What not to do​

  • Do not parse or re-serialise the body before you verify it. The signature covers the raw bytes.
  • Do not alter the secret before you use it. Do not strip whsec_ and do not base64-decode it. The key is the whole string.
  • Do not compare signatures with ==. Use timingSafeEqual or hmac.compare_digest.
  • Do not skip the timestamp check. Do not set the window tighter than your clock skew. The 300-second window is a recommendation, and ARC does not enforce it.
  • Do not use the signature or the timestamp as a dedupe key. Both change on every attempt. Use X-ARC-Delivery, which is the id.
  • Do not return 4xx for a transient problem. Any 4xx other than 408, 425 and 429 fails the delivery permanently. Return a 5xx or a 429 when you want ARC to retry.
  • Do not return 2xx before you have stored the event durably. A 2xx means ARC will not retry.
  • Do not respond with a redirect. ARC does not follow it and retries until the delivery fails. Register the final HTTPS URL.
  • Do not take longer than 10 seconds to answer. Acknowledge fast, then process.
  • Do not assume ordering. Each delivery is independent, so order.created can arrive after order.status_changed. Re-read the document for its current state.
  • Do not assume exactly-once delivery, and do not assume no loss. Retries, slow 2xx responses and replays can cause duplicates. Outages longer than the retry window and periods while an endpoint is disabled can cause gaps. Reconcile from GraphQL.
  • Do not branch on event or parse text. Use type and data.
  • Do not treat data as complete. It is a small summary. For example, order.status_changed has only the new status. Fetch the document.
  • Do not expect events that are not in webhookEventTypes. Shipments, returns, credit notes, payments and purchase documents do not have their own webhook events. A payment or credit note that fully pays a sales invoice is reported only as invoice.paid.
  • Do not store the secret in a browser or in source control. It is shown once. Rotate it if it is exposed.
  • Do not put credentials or tokens in the endpoint URL. ARC refuses a URL with a username and password. Use the signature to authenticate ARC's requests.
  • Do not point an endpoint at localhost, a private address, or a host name that resolves to one. ARC refuses these when you save and again when it sends. For local development, use a public HTTPS tunnel.