Overview
Webhooks let your application react to events in real time without polling. When something happens in Arcus — an order is confirmed, a payment is received, a shipment is created, an inventory adjustment is posted — Arcus sends an HTTP POST to your endpoint with a signed JSON payload. All webhook requests are signed with HMAC-SHA256. You verify the signature using the signing secret returned at endpoint creation.Event structure
Every webhook payload uses this envelope:{
"id": "evt_01H...",
"object": "event",
"type": "order.confirmed",
"created_at": "2026-05-01T10:30:00Z",
"api_version": "2026-05-01",
"entity_id": "ent_01H...",
"data": {
"object": {
"id": "ord_01H...",
"object": "order",
"document_type": "sales_order",
"status": "confirmed"
}
},
"livemode": true
}
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID. Use this for deduplication. |
type | string | Event name (e.g. order.confirmed). See event catalog below. |
created_at | ISO 8601 | When the event fired. |
api_version | string | The API version used to serialize data.object. |
entity_id | string | The entity this event belongs to. |
data.object | object | The full resource at the time of the event. |
livemode | boolean | true for production events; false for test-mode events. |
Registering an endpoint
curl -X POST https://api.arcuserp.com/v1/entities/$ARCUS_ENTITY_ID/webhook_endpoints \
-H "Authorization: Bearer $ARCUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example.com/arcus-webhook",
"enabled_events": ["order.confirmed", "payment.succeeded", "fulfillment.shipped"],
"description": "Production webhook for order pipeline"
}'
secret in whsec_<hex> format. Store it securely — it is
shown once and cannot be retrieved again. Use it to verify every incoming request.
{
"id": "whe_01H...",
"object": "webhook_endpoint",
"url": "https://your-server.example.com/arcus-webhook",
"status": "active",
"enabled_events": ["order.confirmed", "payment.succeeded", "fulfillment.shipped"],
"secret": "whsec_...",
"created_at": "2026-05-01T10:30:00Z"
}
Field name: use
enabled_events (Stripe convention) in all requests and responses.
The legacy alias events is accepted for backward compatibility but is deprecated and will
be removed in API v2.Wildcard subscriptions
Subscribe to an entire event family withorder.*, or all events with *:
{ "enabled_events": ["order.*", "payment.*"] }
<family>.* (14 families) or * (all 118 events).
Duplicate endpoint guard
Registering the same URL + mode combination twice returns HTTP 409 witherror: "duplicate_url"
and the existing_id of the already-active endpoint.
Verifying signatures
Every webhook request includes anArcus-Signature header. Always verify it before processing
the payload. The signature is an HMAC-SHA256 of <timestamp>.<raw_body> using your signing
secret, with a 5-minute replay window.
Header format: Arcus-Signature: t=<unix_epoch>,v1=<hex_hmac_sha256>
- SDK (recommended)
- Raw implementation (no SDK)
import { Webhooks } from '@arcuserp/arcus-node';
// Express example -- use express.raw() to preserve the raw body
app.post('/arcus-webhook', express.raw({ type: 'application/json' }), (req, res) => {
try {
const { timestamp } = Webhooks.verifyWebhookSignature({
payload: req.body.toString(),
signatureHeader: req.headers['arcus-signature'] as string,
secret: process.env.ARCUS_WEBHOOK_SECRET!,
});
// timestamp is the verified unix epoch from the signature header
} catch (err) {
if (err instanceof Webhooks.WebhookSignatureError) {
// err.code is one of: 'missing_header' | 'malformed_header' |
// 'timestamp_too_old' | 'invalid_signature'
return res.status(400).json({ error: err.code, message: err.message });
}
return res.status(400).send('Webhook verification failed');
}
const event = JSON.parse(req.body);
// Process event...
res.status(200).send('ok');
});
from arcuserp.webhooks import verify_webhook_signature, WebhookSignatureError
# Flask example
from flask import Flask, request, abort
import os
app = Flask(__name__)
@app.route('/arcus-webhook', methods=['POST'])
def webhook():
try:
timestamp = verify_webhook_signature(
payload=request.data.decode('utf-8'),
signature_header=request.headers.get('Arcus-Signature', ''),
secret=os.environ['ARCUS_WEBHOOK_SECRET'],
)
# timestamp is the verified unix epoch from the signature header
except WebhookSignatureError as e:
# e.code is one of: 'missing_header', 'malformed_header',
# 'timestamp_too_old', 'invalid_signature'
abort(400, str(e))
event = request.get_json()
# Process event...
return '', 200
import (
"errors"
"net/http"
"io"
"os"
"go.arcuserp.com/sdk/pkg/webhooks"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
ts, err := webhooks.VerifyWebhookSignature(
string(rawBody),
r.Header.Get("Arcus-Signature"),
os.Getenv("ARCUS_WEBHOOK_SECRET"),
300, // tolerance in seconds (5 minutes)
)
if err != nil {
var sigErr *webhooks.SignatureError
if errors.As(err, &sigErr) {
// sigErr.Code is one of: "missing_header", "malformed_header",
// "timestamp_too_old", "invalid_signature"
http.Error(w, sigErr.Code, http.StatusBadRequest)
return
}
http.Error(w, "verification failed", http.StatusBadRequest)
return
}
// ts is the verified unix timestamp from the signature header
_ = ts
// Process event...
w.WriteHeader(http.StatusOK)
}
import crypto from 'crypto';
/**
* Verify an Arcus webhook request.
* @param {string | Buffer} rawBody The raw request body (before JSON.parse).
* @param {string} signature The Arcus-Signature header value.
* @param {string} secret The signing secret (whsec_... from endpoint creation).
* @throws {Error} if the signature is invalid or the timestamp is stale.
*/
function verifyArcusWebhook(rawBody, signature, secret) {
const parts = Object.fromEntries(
signature.split(',').map((p) => p.split('=', 2))
);
const timestamp = parts['t'];
const receivedSig = parts['v1'];
if (!timestamp || !receivedSig) {
throw new Error('Malformed Arcus-Signature header');
}
const payload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const sigBuf = Buffer.from(receivedSig, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
throw new Error('Invalid webhook signature');
}
// Reject events older than 5 minutes (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error('Webhook timestamp too old (> 5 minutes)');
}
}
// Express example -- use express.raw() to preserve the raw body
app.post('/arcus-webhook', express.raw({ type: 'application/json' }), (req, res) => {
try {
verifyArcusWebhook(req.body, req.headers['arcus-signature'], process.env.ARCUS_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(err.message);
}
const event = JSON.parse(req.body);
// Process event...
res.status(200).send('ok');
});
import hashlib
import hmac
import time
import json
def verify_arcus_webhook(raw_body: bytes, signature: str, secret: str) -> None:
"""
Verify an Arcus webhook request.
Args:
raw_body: The raw request body bytes (before JSON parsing).
signature: The Arcus-Signature header value.
secret: The signing secret (whsec_... from endpoint creation).
Raises:
ValueError: if the signature is invalid or the timestamp is stale.
"""
parts = dict(p.split('=', 1) for p in signature.split(','))
timestamp = parts.get('t')
received_sig = parts.get('v1')
if not timestamp or not received_sig:
raise ValueError('Malformed Arcus-Signature header')
payload = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(
secret.encode('utf-8'),
payload.encode('utf-8'),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received_sig):
raise ValueError('Invalid webhook signature')
age = time.time() - int(timestamp)
if age > 300:
raise ValueError('Webhook timestamp too old (> 5 minutes)')
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/arcus-webhook', methods=['POST'])
def webhook():
try:
verify_arcus_webhook(
request.data,
request.headers.get('Arcus-Signature', ''),
os.environ['ARCUS_WEBHOOK_SECRET'],
)
except ValueError as e:
abort(400, str(e))
event = request.get_json()
# Process event...
return '', 200
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// VerifyArcusWebhook verifies an Arcus webhook request.
// rawBody is the raw request body bytes; signature is the Arcus-Signature header value;
// secret is the whsec_... signing secret from endpoint creation.
func VerifyArcusWebhook(rawBody []byte, signature, secret string) error {
parts := make(map[string]string)
for _, p := range strings.Split(signature, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp := parts["t"]
receivedSig := parts["v1"]
if timestamp == "" || receivedSig == "" {
return errors.New("malformed Arcus-Signature header")
}
payload := timestamp + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(receivedSig)) {
return errors.New("invalid webhook signature")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errors.New("invalid timestamp in Arcus-Signature header")
}
if time.Now().Unix()-ts > 300 {
return errors.New("webhook timestamp too old (> 5 minutes)")
}
return nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
if err := VerifyArcusWebhook(rawBody, r.Header.Get("Arcus-Signature"), os.Getenv("ARCUS_WEBHOOK_SECRET")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process event...
w.WriteHeader(http.StatusOK)
}
Webhook request headers
Every delivery includes these headers:| Header | Description |
|---|---|
Arcus-Signature | t=<unix_epoch>,v1=<hex_hmac_sha256> |
Arcus-Webhook-Id | The webhook endpoint ID |
Arcus-Event-Id | The event ID (use for deduplication) |
Arcus-Event-Type | The event type string (e.g. order.confirmed) |
Arcus-API-Version | The API version used to serialize the payload |
Content-Type | application/json |
Responding to webhooks
Return a2xx response within 5 seconds. If your endpoint takes longer, respond immediately
and process the event asynchronously. Arcus retries on any non-2xx response or connection
failure, with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 24 hours |
Deduplication
Events may be delivered more than once (network timeouts, retries). Always deduplicate onevent.id before processing:
const eventId = req.headers['arcus-event-id'];
const existing = await db.processedEvents.findUnique({ where: { id: eventId } });
if (existing) return res.status(200).send('already processed');
await db.processedEvents.create({ data: { id: eventId } });
await processEvent(event);
Rotating signing secrets
curl -X POST https://api.arcuserp.com/v1/entities/$ARCUS_ENTITY_ID/webhook_endpoints/$WEBHOOK_ID/rotate_secret \
-H "Authorization: Bearer $ARCUS_API_KEY"
signing_secret. Update your environment variable immediately. The
old secret is invalidated at rotation — there is no grace period.
Sending a test event
Send awebhook.test event to verify your endpoint is reachable:
curl -X POST https://api.arcuserp.com/v1/entities/$ARCUS_ENTITY_ID/webhook_endpoints/$WEBHOOK_ID/test \
-H "Authorization: Bearer $ARCUS_API_KEY"
Event catalog
Arcus emits 118 event types across 14 families. Subscribe to individual events, a whole family (order.*), or everything (*).
account (12 events)
| Event | Description |
|---|---|
account.created | A new account is created |
account.updated | Account fields are updated |
account.deleted | An account is soft-deleted |
account.merged | Two accounts are merged |
account.address.added | An address is added to an account |
account.address.updated | An account address is updated |
account.address.removed | An account address is removed |
account.contact.added | A contact is added to an account |
account.contact.updated | An account contact is updated |
account.contact.removed | An account contact is removed |
account.credit_limit.updated | The credit limit on an account is changed |
account.terms.updated | Payment terms on an account are changed |
product (21 events)
| Event | Description |
|---|---|
product.created | A new product is created |
product.updated | Product fields are updated |
product.deleted | A product is soft-deleted |
product.restored | A deleted product is restored |
product.variant.created | A product variant is created |
product.variant.updated | A product variant is updated |
product.variant.deleted | A product variant is deleted |
product.kit.component.added | A component is added to a kit |
product.kit.component.updated | A kit component is updated |
product.kit.component.removed | A kit component is removed |
product.pricing.added | A pricing policy is added to a product |
product.pricing.updated | A product pricing policy is updated |
product.pricing.removed | A product pricing policy is removed |
product.vendor.added | A vendor is linked to a product |
product.vendor.updated | A product-vendor relationship is updated |
product.vendor.removed | A vendor is unlinked from a product |
product.listing.created | A marketplace listing is created |
product.listing.updated | A marketplace listing is updated |
product.listing.removed | A marketplace listing is removed |
product.listing.paused | A marketplace listing is paused |
product.listing.resumed | A paused marketplace listing is resumed |
order (16 events)
| Event | Description |
|---|---|
order.created | A quote or sales order is created |
order.updated | Order fields are updated |
order.confirmed | A quote is confirmed as a sales order |
order.cancelled | An order is cancelled |
order.shipped | An order is marked shipped |
order.invoiced | A sales order is converted to an invoice |
order.partially_fulfilled | Some but not all order lines are fulfilled |
order.fulfilled | All order lines are fulfilled |
order.line_item.added | A line item is added to an order |
order.line_item.updated | An order line item is updated |
order.line_item.removed | An order line item is removed |
order.tax.recalculated | Tax is recalculated for an order |
order.revised | A SENT invoice or sales order is revised (a customer-visible field changed, or a line item was added/updated/removed after the document was sent). Payload: order_id, order_number, document_type, revision_count, revised_at, revised_by_user_id, revision_summary, changed_fields[]. |
order.credit_hold_overridden | A permissioned operator pushed an order past a credit-hold gate with a typed reason |
order.credit_hold.override_notified | Finance-team notification fanout succeeded after a credit-hold override (at least one recipient was emailed) |
order.credit_hold.override_notify_failed | The credit-hold override notification fanout failed for every configured recipient (Postmark / DNS / config issue) |
invoice (9 events)
| Event | Description |
|---|---|
invoice.created | An invoice is created |
invoice.updated | Invoice fields are updated |
invoice.paid | An invoice is fully paid |
invoice.partially_paid | A partial payment is applied to an invoice |
invoice.voided | An invoice is voided |
invoice.refunded | An invoice is refunded |
invoice.overdue | An invoice becomes past its due date |
invoice.sent | An invoice is emailed to the customer |
invoice.viewed_by_customer | A customer views the invoice portal page (future: requires portal beacon) |
payment (8 events)
| Event | Description |
|---|---|
payment.created | A payment record is created |
payment.succeeded | A payment is successfully processed |
payment.failed | A payment attempt fails |
payment.refunded | A payment is fully refunded |
payment.partially_refunded | A partial refund is issued |
payment.disputed | A payment dispute (chargeback) is opened |
payment.dispute.won | A dispute is resolved in your favor |
payment.dispute.lost | A dispute is resolved against you |
inventory (7 events)
| Event | Description |
|---|---|
inventory.adjusted | An inventory adjustment is posted |
inventory.transferred | Inventory is transferred between locations |
inventory.reclassified | Inventory is reclassified |
inventory.low_stock | On-hand falls to at or below the product’s reorder point (and is still above zero). Debounced to at most once per product per location per hour. |
inventory.out_of_stock | On-hand reaches zero (or below). Debounced to at most once per product per location per hour. |
inventory.back_in_stock | A product that was at zero on-hand is replenished above zero. Debounced to at most once per product per location per hour. |
inventory.cycle_count.completed | A cycle count is completed |
purchase_order (8 events)
| Event | Description |
|---|---|
purchase_order.created | A purchase order is created |
purchase_order.updated | Purchase order fields are updated |
purchase_order.approved | A purchase order is approved |
purchase_order.rejected | A purchase order is rejected |
purchase_order.received | All items on a PO are received |
purchase_order.partially_received | Some items on a PO are received |
purchase_order.cancelled | A purchase order is cancelled |
purchase_order.closed | A purchase order is closed |
vendor_bill (8 events)
| Event | Description |
|---|---|
vendor_bill.created | A vendor bill is created |
vendor_bill.updated | Vendor bill fields are updated |
vendor_bill.approved | A vendor bill is approved |
vendor_bill.rejected | A vendor bill is rejected |
vendor_bill.posted | A vendor bill is posted to the GL |
vendor_bill.paid | A vendor bill is paid |
vendor_bill.voided | A vendor bill is voided |
vendor_bill.written_off | A vendor bill balance is written off as income recognition (GAAP: FASB ASC 405-20). Payload includes amount_written_off and journal_entry_id. |
journal_entry and period (5 events)
| Event | Description |
|---|---|
journal_entry.posted | A journal entry is posted to the GL |
journal_entry.reversed | A journal entry is reversed |
journal_entry.source_relinked | A migrated journal entry’s source_id has been corrected from a source-system integer to the Arcus order/invoice UUID (MIGRATION-GL-DIMENSIONS-REPORT-READY 2026-05-21; extended 2026-05-27 to also fire when source_type is upgraded from VERSA_MIGRATION to a canonical value or description is enriched with the order number). Payload includes the journal_entry id. |
period.closed | An accounting period is closed |
period.reopened | A closed accounting period is reopened |
fulfillment (7 events)
| Event | Description |
|---|---|
fulfillment.created | A fulfillment package is created |
fulfillment.label_purchased | A shipping label is purchased |
fulfillment.label_voided | A shipping label is voided |
fulfillment.shipped | A package is marked shipped |
fulfillment.in_transit | Carrier reports the package is in transit |
fulfillment.delivered | Carrier reports delivery |
fulfillment.exception | A carrier delivery exception occurs |
return (5 events)
| Event | Description |
|---|---|
return.created | An RMA is created |
return.received | Return items are received back |
return.disposed | Returned items are disposed |
return.refunded | A return is refunded |
return.cancelled | An RMA is cancelled |
connector (7 events)
| Event | Description |
|---|---|
connector.connected | A connector (Shopify, Amazon, etc.) is connected |
connector.disconnected | A connector is disconnected |
connector.sync_started | A connector sync run starts |
connector.sync_completed | A connector sync run completes successfully |
connector.sync_failed | A connector sync run fails |
connector.token_refreshed | A connector OAuth token is refreshed |
connector.token_expired | A connector OAuth token expires |
migration (10 events)
Migration events are registered but only fire when the
API-RESOURCE-MIGRATION feature is
complete. Until then, subscribers see zero migration.* deliveries.| Event | Description |
|---|---|
migration.batch_started | A migration batch import starts |
migration.batch_completed | A migration batch import completes |
migration.batch_failed | A migration batch import fails |
migration.cutover_initiated | A migration cutover is initiated |
migration.cutover_verified | A cutover verification step passes |
migration.cutover_completed | Cutover completes successfully |
migration.cutover_rolled_back | A cutover is rolled back |
migration.snapshot_taken | A pre-cutover data snapshot is taken |
migration.freeze_engaged | The source system write freeze is engaged |
migration.unfreeze_engaged | The write freeze is lifted |
webhook (1 event)
| Event | Description |
|---|---|
webhook.test | Synthetic test event sent by the /test endpoint |

