# 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

1. **Event occurs** (e.g., transaction approved)
2. **AuxCORE sends an HTTP POST** with a JSON body to every active endpoint of that merchant subscribed to the event
3. **Your server responds** with any `2xx` status within 30 seconds
4. **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.

```javascript
// 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)

```http
POST /api/v1/webhooks/endpoints
```

| 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` |

```bash
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):**
```json
{
  "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)

```http
POST /api/v1/public/register-webhook
```

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) |

```bash
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:

```json
{
  "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](../reference/webhook-events.md#dispute-events) for their payload.

### Transaction event data

All `transaction.*` events carry the same `data` body:

```json
{
  "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.method` is `card`, `ach`, `crypto` or `cash`.
- Hosted checkout payments also carry `sessionId` and `refNo`.
- For refunds (`transaction.refunded` / `transaction.refund_failed`), `originalTransactionId` points at the sale.

### Session event data

```json
{
  "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

```javascript
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

```python
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', 200
```

---

## Handling Webhooks

### Best Practices

1. **Respond quickly** - Return a 2xx immediately (the request times out after 30 seconds)
2. **Process asynchronously** - Queue heavy processing
3. **Be idempotent** - Handle duplicate events gracefully
4. **Verify signatures** - Always verify before processing
5. **Log events** - Keep audit trail of all webhooks
6. **Handle retries** - Use `X-Webhook-Event-ID` to deduplicate

### Example Handler

```javascript
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

```bash
# 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/auxcore
```

### Send a Test Event

```http
POST /api/v1/webhooks/endpoints/:id/test
```

Sends a signed `webhook.test` event (`data: { message, timestamp }`) to the endpoint and returns the delivery result.

```bash
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

```http
GET /api/v1/webhooks/endpoints
```

```bash
curl https://app.auxcore.net/api/v1/webhooks/endpoints \
  -H "Authorization: Bearer YOUR_TOKEN"
```

### Get a Webhook

```http
GET /api/v1/webhooks/endpoints/:id
```

### Update Webhook

```http
PATCH /api/v1/webhooks/endpoints/:id
```

Any of `url`, `description`, `events`, `isActive`, `maxRetries`, `retryDelaySeconds`.

```bash
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

```http
DELETE /api/v1/webhooks/endpoints/:id
```

```bash
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

```http
POST /api/v1/webhooks/endpoints/:id/regenerate-secret
```

Returns the new secret in `data.secret`. The old secret stops verifying immediately.

### Delivery History

```http
GET /api/v1/webhooks/endpoints/:id/deliveries
```

### Retry a Delivery

```http
POST /api/v1/webhooks/deliveries/:deliveryId/retry
```

---

## 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

1. Check the endpoint is active and subscribed to the event (`GET /api/v1/webhooks/endpoints`)
2. Verify the URL is correct and publicly reachable
3. Check firewall/IP whitelist settings
4. Review delivery history (`GET /api/v1/webhooks/endpoints/:id/deliveries`)

### Signature Verification Fails

1. Ensure you're using the current secret — registering the same URL again through `register-webhook`, or regenerate-secret, rotates it
2. Verify you're signing `<t>.<raw body>`, not a re-serialized object
3. Check your server clock — signatures older than 5 minutes should be rejected
4. Make sure charset is UTF-8

### Webhooks Timing Out

1. Respond with 200 OK immediately
2. Queue processing for async handling
3. Don't wait for external services
4. Keep response time < 5 seconds

---

## Complete Event Reference

[View all webhook events →](../reference/webhook-events.md)

---

## Next Steps

- [Handle errors in webhooks →](./errors.md)
- [View code examples →](../examples/curl.md)
- [Learn about idempotency →](./errors.md)

---

**Need help?** Contact support@auxcore.net
