Subscribe to real-time events from the Arcus API. Accounts, products, orders, invoices, payments, inventory, purchasing, fulfillment, returns, and more — 118 event types across 14 families.
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.
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" }'
The response includes a secret in whsec_<hex> format. Store it securely — it is
shown once and cannot be retrieved again. Use it to verify every incoming request.
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.
Every webhook request includes an Arcus-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 bodyapp.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 examplefrom flask import Flask, request, abortimport osapp = 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" "github.com/arcuserp/arcus-sdk-go/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 bodyapp.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 hashlibimport hmacimport timeimport jsondef 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 examplefrom flask import Flask, request, abortapp = 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 mainimport ( "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)}
Return a 2xx 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
After 6 failed attempts, no more retries are made for that delivery. An endpoint that accumulates
10 consecutive permanent failures is automatically disabled.
curl -X POST https://api.arcuserp.com/v1/entities/$ARCUS_ENTITY_ID/webhook_endpoints/$WEBHOOK_ID/rotate_secret \ -H "Authorization: Bearer $ARCUS_API_KEY"
The response returns a new signing_secret. Update your environment variable immediately. The
old secret is invalidated at rotation — there is no grace period.
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)
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.
Migration events are registered but only fire when the API-RESOURCE-MIGRATION feature is
complete. Until then, subscribers see zero migration.* deliveries.