Webhooks#
Receive real-time notifications about events in your AuxCORE account.
Overview#
Webhooks allow AuxCORE to push real-time notifications to your server when a payment changes state:
- ✅ Transactions approved, pending or declined
- 💰 Refunds processed or failed
- ↩️ ACH debits returned
- 🚫 Transactions voided
- ⌛ Hosted checkout sessions cancelled or expired
Instead of polling our API, webhooks push data to you as events happen.
How Webhooks Work#
- Event occurs (e.g., transaction approved)
- AuxCORE sends an HTTP POST with a JSON body to every active endpoint of that merchant subscribed to the event
- Your server responds with any
2xxstatus within 30 seconds - If it fails, AuxCORE retries with exponential backoff
Setup#
1. Create a Webhook Endpoint#
Create an endpoint on your server to receive webhook notifications. Keep the raw request body — the signature is computed over the exact bytes sent.
// Express.js example
app.post('/webhooks/auxcore', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const rawBody = req.body.toString('utf8');
if (!verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
try {
await handleWebhookEvent(JSON.parse(rawBody));
res.status(200).send('OK');
} catch (error) {
console.error('Webhook error:', error);
res.status(500).send('Error processing webhook');
}
});2. Register Your Webhook URL#
Webhook endpoints belong to a merchant. Register them with a dashboard JWT on the Admin API, or with an API key through the Public API.
Create an endpoint (Admin API)#
| Body field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | Your HTTPS endpoint (must be a valid URL) |
events |
string[] | Yes | Event types to subscribe to — see Event Types. Unknown names are rejected |
description |
string | No | Label shown in the dashboard |
merchantId |
string | No | Admins only: the merchant to create it for. Other users always create it for their own merchant |
maxRetries |
integer | No | Total delivery attempts, default 3 |
retryDelaySeconds |
integer | No | Base retry delay, default 60 |
curl -X POST https://app.auxcore.net/api/v1/webhooks/endpoints \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yoursite.com/webhooks/auxcore",
"events": [
"transaction.approved",
"transaction.declined",
"transaction.refunded",
"transaction.refund_failed"
],
"description": "Production webhook"
}'Response (201):
{
"success": true,
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"tenantId": "your-tenant",
"merchantId": "550e8400-e29b-41d4-a716-446655440000",
"isSystemWebhook": false,
"url": "https://yoursite.com/webhooks/auxcore",
"description": "Production webhook",
"events": ["transaction.approved", "transaction.declined", "transaction.refunded", "transaction.refund_failed"],
"signatureHeader": "X-Webhook-Signature",
"isActive": true,
"maxRetries": 3,
"retryDelaySeconds": 60,
"hasSecret": true,
"secretHint": "…9f3a",
"secret": "4f1d0c6b2a9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e9f3a"
},
"message": "Store the signing secret now — it cannot be retrieved again."
}⚠️ Save the secret! It is returned only here (and by regenerate-secret). List, get and update responses show only hasSecret and secretHint.
Errors: 400 VALIDATION_ERROR (url or events missing), 400 UNKNOWN_EVENT_TYPE (lists the valid events), 400 INVALID_URL.
Register with an API key (Public API)#
Used by plugins and server integrations. Requires x-api-key; the merchant comes from the key (or X-Merchant-ID for ERP keys).
| Body field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | Your endpoint URL |
events |
string[] | No | Defaults to the ten transaction and session events listed below (dispute events must be listed explicitly) |
curl -X POST https://app.auxcore.net/api/v1/public/register-webhook \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://yoursite.com/webhooks/auxcore" }'The call is an upsert by URL. A new URL returns 201 with the endpoint and data.secret. A URL that is already registered returns 200, updates its events, re-activates it, resets maxRetries / retryDelaySeconds to 3 / 60 and rotates the secret — the old secret stops verifying immediately, so store the new data.secret. A missing url returns 400 with error: "url is required"; if the rotation issues no secret the call returns 502 SECRET_ROTATION_FAILED.
Webhook Payload Structure#
All webhooks follow this format:
{
"id": "0b8f3d2c-5a1e-4f6b-9c7d-2e4a6b8c0d1f",
"type": "transaction.approved",
"created": "2026-08-10T12:34:56.000Z",
"data": {
// Event-specific data
},
"metadata": {
"tenantId": "your-tenant",
"merchantId": "550e8400-e29b-41d4-a716-446655440000"
}
}Fields#
| Field | Type | Description |
|---|---|---|
id |
string | Unique event ID (also sent as X-Webhook-Event-ID) |
type |
string | Event type (e.g. transaction.approved) |
created |
string | ISO 8601 time the event was created |
data |
object | Event-specific data |
metadata.tenantId |
string | Your tenant ID |
metadata.merchantId |
string | Associated merchant ID |
Request headers#
| Header | Description |
|---|---|
X-Webhook-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-Webhook-Event-Type |
Same as type |
X-Webhook-Event-ID |
Same as id — use it to de-duplicate |
X-Webhook-Delivery-ID |
Unique per delivery attempt chain |
User-Agent |
AuxVault-Webhooks/1.0 |
Content-Type |
application/json |
Event Types#
These are the only events you can subscribe to:
| Event | Sent when |
|---|---|
transaction.approved |
A sale or authorization is approved, or later settles |
transaction.pending |
A payment is accepted but not yet settled (e.g. ACH) |
transaction.in_review |
A payment is approved by the processor but held by one of the merchant's Transaction Rules until the merchant reviews it — keep the order on hold until transaction.approved or transaction.voided |
transaction.declined |
A payment is declined |
transaction.voided |
A transaction is voided |
transaction.refunded |
A refund is approved |
transaction.refund_failed |
A refund is declined |
transaction.returned |
An ACH debit is returned by the bank after it was accepted (the R-code is in data.responseText, e.g. ACH returned (R01) — ...). Hosted-checkout payments are also announced as transaction.declined with the same body, for older stores |
session.cancelled |
The shopper cancels a hosted checkout session |
session.expired |
A hosted checkout session expires, or the shopper never completes payment |
chargeback.received |
A dispute case is opened (API, processor notification or bank-file import) |
transaction.chargeback |
A transaction is flagged as charged back from the dashboard |
chargeback.open |
A case's status changes to open |
chargeback.under_review |
A case's status changes to under_review |
chargeback.won |
A case's status changes to won |
chargeback.lost |
A case's status changes to lost — including when the merchant accepts the dispute |
chargeback.expired |
A case's status changes to expired (lapsed or prevented) |
The ten transaction and session events are the default set. Dispute events are sent only to endpoints that list them in events — see Dispute Events for their payload.
Transaction event data#
All transaction.* events carry the same data body:
{
"id": "0b8f3d2c-5a1e-4f6b-9c7d-2e4a6b8c0d1f",
"type": "transaction.approved",
"created": "2026-08-10T12:34:56.000Z",
"data": {
"transactionId": "TXN-1776037573138-56123064",
"id": "550e8400-e29b-41d4-a716-446655440020",
"type": "sale",
"status": "approved",
"amount": 153.75,
"baseAmount": 150.00,
"convenienceFee": 3.75,
"tipAmount": 0,
"taxAmount": 0,
"currency": "USD",
"authCode": "123456",
"responseCode": "100",
"responseText": "Approved",
"gatewayTransactionId": "8812734455",
"gatewayName": "NMI",
"provider": "NMI",
"paymentMethod": "card",
"cardLast4": "1111",
"cardBrand": "visa",
"orderId": "1042",
"invoiceNumber": null,
"billingEmail": "customer@example.com",
"originalTransactionId": null,
"requestOrigin": "api",
"payment": { "method": "card" },
"merchantId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-08-10T12:34:56.000Z"
},
"metadata": {
"tenantId": "your-tenant",
"merchantId": "550e8400-e29b-41d4-a716-446655440000"
}
}payment.methodiscard,ach,cryptoorcash.- Hosted checkout payments also carry
sessionIdandrefNo. - For refunds (
transaction.refunded/transaction.refund_failed),originalTransactionIdpoints at the sale.
Session event data#
{
"sessionId": "cs_3f9c2b7e0d",
"refNo": "REF-1042",
"orderId": "1042",
"merchantId": "550e8400-e29b-41d4-a716-446655440000",
"transactionId": "TXN-1776037573138-56123064",
"status": "error",
"reason": "checkout_not_completed",
"timestamp": "2026-08-10T12:34:56.000Z"
}Note: Subscription, invoice, chargeback and settlement events are not available as webhooks.
Signature Verification#
Always verify webhook signatures to ensure requests are from AuxCORE.
How It Works#
The X-Webhook-Signature header looks like t=1786451696,v1=5f2b…. AuxCORE computes v1 as the hex HMAC-SHA256, keyed with your endpoint secret, of the string <t>.<raw request body>. Reject the request if the signature does not match, or if t is more than 5 minutes from your clock (replay protection).
Node.js Example#
const crypto = require('crypto');
function verifySignature(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
const a = Buffer.from(parts.v1, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python Example#
import hmac
import hashlib
import os
import time
def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
parts = dict(p.split('=', 1) for p in header.split(','))
timestamp = int(parts.get('t', 0))
v1 = parts.get('v1')
if not timestamp or not v1 or abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
# In your webhook handler (Flask)
@app.route('/webhooks/auxcore', methods=['POST'])
def webhook():
if not verify_signature(request.get_data(), request.headers.get('X-Webhook-Signature'), os.environ['WEBHOOK_SECRET']):
return 'Invalid signature', 401
# Process webhook
return 'OK', 200Handling Webhooks#
Best Practices#
- Respond quickly - Return a 2xx immediately (the request times out after 30 seconds)
- Process asynchronously - Queue heavy processing
- Be idempotent - Handle duplicate events gracefully
- Verify signatures - Always verify before processing
- Log events - Keep audit trail of all webhooks
- Handle retries - Use
X-Webhook-Event-IDto deduplicate
Example Handler#
const processedEvents = new Set();
async function handleWebhookEvent(payload) {
const { id, type, data } = payload;
// Check if already processed (idempotency)
if (processedEvents.has(id)) {
console.log(`Event ${id} already processed`);
return;
}
// Mark as processed
processedEvents.add(id);
// Route to appropriate handler
switch (type) {
case 'transaction.approved':
await markOrderPaid(data.orderId, data.transactionId);
break;
case 'transaction.declined':
await markOrderFailed(data.orderId, data.responseText);
break;
case 'transaction.returned':
await reverseOrder(data.orderId, data.responseText);
break;
case 'transaction.refunded':
await recordRefund(data.originalTransactionId, data.amount);
break;
default:
console.log(`Unhandled event type: ${type}`);
}
}Retry Logic#
A delivery fails on any non-2xx response, a network error, or no response within 30 seconds. Failed deliveries are retried until the endpoint's maxRetries attempts (default 3, including the first) have been made. The delay before attempt n + 1 is retryDelaySeconds × 2^(n − 1):
| Attempt | Delay (defaults) |
|---|---|
| 1 | Immediate |
| 2 | ~1 minute |
| 3 | ~2 minutes |
Retries are picked up by a sweep that runs about once a minute, so actual delays can be up to a minute longer. After the last attempt the delivery is marked failed; you can retry it manually (see Managing Webhooks).
Testing Webhooks#
Local Development with ngrok#
# Install ngrok
npm install -g ngrok
# Start your local server
node server.js # Running on localhost:3000
# Create tunnel
ngrok http 3000
# Use the ngrok URL as your webhook URL
# https://abc123.ngrok.io/webhooks/auxcoreSend a Test Event#
Sends a signed webhook.test event (data: { message, timestamp }) to the endpoint and returns the delivery result.
curl -X POST https://app.auxcore.net/api/v1/webhooks/endpoints/7c9e6679-7425-40de-944b-e07fc1f90ae7/test \
-H "Authorization: Bearer YOUR_TOKEN"Managing Webhooks#
All management routes require Authorization: Bearer <JWT>. Admins see every endpoint in the tenant (filter with ?merchantId=); other users see only their merchant's.
List Webhooks#
curl https://app.auxcore.net/api/v1/webhooks/endpoints \
-H "Authorization: Bearer YOUR_TOKEN"Get a Webhook#
Update Webhook#
Any of url, description, events, isActive, maxRetries, retryDelaySeconds.
curl -X PATCH https://app.auxcore.net/api/v1/webhooks/endpoints/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"events": ["transaction.approved", "transaction.declined"],
"isActive": true
}'Delete Webhook#
curl -X DELETE https://app.auxcore.net/api/v1/webhooks/endpoints/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
-H "Authorization: Bearer YOUR_TOKEN"Rotate the Signing Secret#
Returns the new secret in data.secret. The old secret stops verifying immediately.
Delivery History#
Retry a Delivery#
Security Best Practices#
✅ DO:#
- Verify signatures on every request, including the timestamp
- Use HTTPS for webhook URLs (the API only checks that the URL is well-formed)
- Log all webhooks for audit trail
- Use idempotency to handle duplicates
- Respond quickly (< 5 seconds; hard limit 30 seconds)
- Queue heavy processing for async handling
❌ DON'T:#
- Don't skip signature verification
- Don't re-serialize the body before verifying — use the raw bytes
- Don't process synchronously if it takes > 5 seconds
- Don't trust the data without verification
Troubleshooting#
Webhook Not Received#
- Check the endpoint is active and subscribed to the event (
GET /api/v1/webhooks/endpoints) - Verify the URL is correct and publicly reachable
- Check firewall/IP whitelist settings
- Review delivery history (
GET /api/v1/webhooks/endpoints/:id/deliveries)
Signature Verification Fails#
- Ensure you're using the current secret — registering the same URL again through
register-webhook, or regenerate-secret, rotates it - Verify you're signing
<t>.<raw body>, not a re-serialized object - Check your server clock — signatures older than 5 minutes should be rejected
- Make sure charset is UTF-8
Webhooks Timing Out#
- Respond with 200 OK immediately
- Queue processing for async handling
- Don't wait for external services
- Keep response time < 5 seconds
Complete Event Reference#
Next Steps#
Need help? Contact support@auxcore.net