# Webhooks

Fluxis uses **account-level** webhooks to notify your application in real time when events occur, such as payment status changes. This guide covers registration, delivery behavior, and a short overview of signature verification.

For the full HMAC verification walkthrough (canonical JSON, worked example, test endpoint), see [Webhook Signature Verification](/docs/developers/webhook-signature-verification).

## Overview

When you register a webhook for an **account**, Fluxis sends HTTP POST requests to your URL whenever matching events occur. Each request includes:

- A JSON payload with event data
- Security headers for signature verification

::::warning
Always verify webhook signatures before processing the payload. See [Webhook Signature Verification](/docs/developers/webhook-signature-verification).
::::

## Webhook headers

Every webhook request includes the following headers:

| Header                | Description                                             |
| --------------------- | ------------------------------------------------------- |
| `X-Request-Signature` | HMAC-SHA256 signature of the payload                    |
| `X-Request-Timestamp` | Unix timestamp (seconds) when the request was generated |
| `Content-Type`        | Always `application/json`                               |

## Registering webhooks

Webhooks are managed at the **account** level (not per Point of Sale). Use your organization credentials to register an HTTPS endpoint for the account tied to your POS.

**Create webhook:** `POST /v1/account/{accountID}/webhook`

```json
{
  "url": "https://your-server.com/webhooks/fluxis",
  "event_type": "payment_request",
  "description": "Order status updates"
}
```

The response includes a `secret` — **store it immediately**; it is only returned on create.

| `event_type`        | When it fires                                      |
| ------------------- | -------------------------------------------------- |
| `payment_request`   | Payment request status changes (see below)         |
| `incoming_transfer` | Incoming transfer events (reserved / future use) |
| `refund`            | Refund events                                      |

**Other management endpoints:**

| Action            | Endpoint                                                          |
| ----------------- | ----------------------------------------------------------------- |
| List              | `GET /v1/account/{accountID}/webhook/list`                        |
| Update URL        | `PUT /v1/account/{accountID}/webhook/{webhookID}/url`             |
| Activate          | `PATCH /v1/account/{accountID}/webhook/{webhookID}/activate`      |
| Deactivate        | `PATCH /v1/account/{accountID}/webhook/{webhookID}/deactivate`    |
| Delete            | `DELETE /v1/account/{accountID}/webhook/{webhookID}/delete`       |
| Test delivery     | `POST /v1/account/{accountID}/webhook/{webhookID}/test`           |
| Delivery logs     | `GET /v1/account/{accountID}/webhook/logs?page=1&page_size=20`    |

See the [API Reference — Webhooks](/api/webhooks#create-webhook).

::::note
Webhook management requires **organization-level** API credentials. The account must belong to your organization.
::::

---

## Verifying webhook signatures (summary)

Fluxis signs the **full parsed JSON body** — for example `{ "data": { … } }` — not raw request bytes and not the inner `data` object alone.

**Algorithm:**

1. Serialize the parsed body to **canonical JSON** (compact, keys sorted alphabetically at every object level)
2. Build the signed string: `<unix_timestamp>.<canonical_json>`
3. Compute `HMAC-SHA256(webhook_secret, signed_string)` and hex-encode the result
4. Compare to `X-Request-Signature` using a constant-time function
5. Reject if the timestamp is older than **300 seconds** (5 minutes)

**Signed string example** (timestamp `1712345678`, sample `payment_request` payload):

```
1712345678.{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

::::note Optional fields
Fields such as `external_reference_id` and `account_id` use `omitempty` in the API — they are omitted from the JSON when unset. Your verifier must canonicalize the **parsed body as received**, not a fixed schema template.
::::

**Quick Node.js check** (see the [full guide](/docs/developers/webhook-signature-verification) for Python, Go, and a worked example):

```typescript
import crypto from "crypto";

function sortKeys(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === "object") {
    return Object.keys(value as object)
      .sort()
      .reduce((acc, key) => {
        acc[key] = sortKeys((value as Record<string, unknown>)[key]);
        return acc;
      }, {} as Record<string, unknown>);
  }
  return value;
}

function verifyWebhookSignature(
  payload: unknown,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  const requestTimestamp = parseInt(timestamp, 10);
  if (Math.floor(Date.now() / 1000) - requestTimestamp > 300) return false;

  const canonicalJson = JSON.stringify(sortKeys(payload));
  const signedString = `${timestamp}.${canonicalJson}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedString)
    .digest("hex");

  if (signature.length !== expectedSignature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature, "utf8"),
    Buffer.from(expectedSignature, "utf8"),
  );
}
```

---

## Which statuses trigger webhooks

Fluxis delivers `payment_request` webhooks when a payment request enters **`processing`** (payment detected) or a terminal confirmation state (**`completed`**, **`underpaid`**, or **`overpaid`**).

Status changes such as **`expired`** or **`failed`** are **not** sent via webhook — poll the payment request endpoint or reconcile via transactions.

## Webhook payload structure

Payment request status update webhooks have the following structure:

```json
{
  "data": {
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "external_reference_id": "your-order-id-123",
    "status": "completed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d"
  }
}
```

| Field                   | Description                                                  |
| ----------------------- | ------------------------------------------------------------ |
| `notification_type`     | Type of notification (e.g., `payment_request_status_update`) |
| `payment_request_id`    | Unique identifier of the payment request                     |
| `payment_request_type`  | Type of payment request (e.g., `fixed`)                      |
| `external_reference_id` | Your internal reference ID (optional; omitted if not set)      |
| `status`                | New status of the payment request                            |
| `point_of_sale_id`      | ID of the Point of Sale                                      |
| `account_id`            | ID of the associated account (optional; omitted if not set)    |

---

## Best practices

### Respond quickly

Your endpoint must return HTTP `200 OK` within **2 seconds**. If you need to perform heavy processing, acknowledge the webhook immediately and queue the work for background execution.

```typescript
app.post("/webhooks/fluxis", async (req, res) => {
  if (!verifyWebhookSignature(req.body, /* ... */)) {
    return res.status(401).send();
  }
  res.status(200).json({ received: true });
  processWebhookAsync(req.body).catch(console.error);
});
```

### Handle duplicates (idempotency)

You may receive the same webhook event multiple times. Use the `payment_request_id` and `status` to deduplicate.

### Implement retry logic awareness

If your endpoint does not respond with HTTP `200`, Fluxis may retry the webhook. Ensure your handler is idempotent.

---

## Testing webhooks

1. Expose your local server with [ngrok](https://ngrok.com) or similar during development.
2. Register the public URL via `POST /v1/account/{accountID}/webhook`.
3. Trigger a test delivery: `POST /v1/account/{accountID}/webhook/{webhookID}/test` — uses the same signing path as production.
4. Confirm your handler verifies the signature and returns HTTP `200`.

See [Webhook Signature Verification](/docs/developers/webhook-signature-verification) for step-by-step verification and common failure modes.

---

## Troubleshooting

| Issue | What to check |
| ----- | ------------- |
| Signature fails | Correct secret from create response; sign parsed body `{data:…}`; canonical JSON rules — [full guide](/docs/developers/webhook-signature-verification) |
| Not receiving events | Public HTTPS URL; HTTP `200` within 2s; webhook enabled; correct `event_type` |
| Missing `expired` / `failed` webhooks | Expected — poll `GET /v1/pos/{posID}/payment-request/{paymentRequestID}` |

---

## Need help?

Contact [support@fluxis.us](mailto:support@fluxis.us) with your account ID, webhook ID, and timestamp of the problematic delivery.
