# Webhooks

Fynex pushes lifecycle events to a webhook URL you register on your seller account. Every outbound request is signed with HMAC-SHA256 so you can verify it came from Fynex and was not modified in transit.

> [!IMPORTANT]
> The signing secret is returned **once** on the create response. Store it somewhere safe (a secret manager, your platform's encrypted config). If you lose it, rotate via the dashboard to mint a new one.

## Register a webhook URL

`POST /payments-api/v1/webhooks` registers a URL and returns the per-config signing secret.

### curl

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/webhooks" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"webhookUrl":"https://merchant.example.com/webhooks/fynex"}'
```

**Response (201 Created):**

```json
{
  "id": 1,
  "merchantId": "16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117",
  "webhookUrl": "https://merchant.example.com/webhooks/fynex",
  "status": "active",
  "secretKey": "3b8f1d2c4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c",
  "createdAt": "2026-05-19T10:11:12Z",
  "updatedAt": "2026-05-19T10:11:12Z"
}
```

`secretKey` is **only** returned on this response. Subsequent `GET /webhooks` and `PATCH /webhooks/{id}` responses omit it. If you lose the value, use the dashboard's rotate-secret action to mint a fresh one — the old secret stops verifying as soon as the new one is issued.

If the same `webhookUrl` already exists in `disabled` state for your account, the endpoint reactivates the existing row and returns **200 OK** with the original secret (not a new one). This means rotating status `disabled → active` does not change the secret your verifier needs.

## Choosing what you receive

An endpoint registered without an `eventTypes` field receives **every event
type except those marked opt-in below**. That is the default, and it is what
every endpoint created before subscriptions existed still does — you do not
have to change anything.

To narrow an endpoint, send the list you want:

```json
{
  "webhookUrl": "https://merchant.example.com/webhooks/fynex",
  "eventTypes": ["PaymentCompleted", "PaymentRefunded"]
}
```

A non-empty list is an allow-list: exactly those types and nothing else. An
unrecognised type is rejected with `400`, so a typo cannot leave your endpoint
silently receiving nothing. `PATCH /webhooks/{id}` replaces the list wholesale;
send `"eventTypes": []` to go back to the default.

Two things worth knowing before you narrow an endpoint:

- **Only `200` counts as delivered.** Everything else is retried up to three
  times and then marked failed, so returning a non-2xx for an event you did not
  want costs you a retry storm rather than filtering anything. Narrow the
  subscription instead.
- **Ignore unknown `eventType` values.** New types are added over time. An
  endpoint on the default subscription will start receiving new non-opt-in
  types without any action from you, so treat an unfamiliar `eventType` as a
  no-op rather than an error.

## What you can receive

Every event below is also published in the OpenAPI document under its
top-level `webhooks` key, so a generated client carries the delivered body
type instead of a map. Register once and you receive all of them, unless you
narrow the endpoint with `eventTypes` as described above.

### Payments

| Event | Fires when |
| --- | --- |
| `PaymentCompleted` | A payment first reached `captured`, `failed` or `cancelled`; it fires once per payment. `lifecycleStatus` says which one — this is not by itself a success, and `failureCode` / `retry` / `failureDescription` carry the reason, and whether re-sending can help, when it is not. When the payment used a coupon or wallet credit, the payload carries an optional `promotion` object. |
| `PaymentRefunded` | A refund succeeded. `partial` is true when it is smaller than the payment's captured amount, per refund rather than cumulatively. A FAILED refund emits nothing; it surfaces as a seller email. |
| `PaymentPromotionCompleted` | An order was paid in full by a coupon and promotional credit, so no card or bank payment ran. Such an order sends **no** `PaymentCompleted`; this is its completion signal. See [Promotions](#promotions). |
| `PaymentPromotionCancelled` | An order a coupon and promotional credit paid in full was cancelled (by you or by Fynex support). No cash moved, so **no** `PaymentRefunded` is sent; the payment is now `refunded`. See [Promotions](#promotions). |

### Payouts

This family is **opt-in**: an endpoint receives it only when its `eventTypes`
names the events you want, so existing endpoints never start receiving payout
events unasked. Naming them makes the list an allow-list, so include every
other event the endpoint should keep receiving — or register a second
endpoint for payouts alone.

| Event | Fires when |
| --- | --- |
| `PayoutCompleted` | A payout reached `completed`: the banking provider confirmed it and the held funds left the wallet. |
| `PayoutFailed` | A payout reached `failed`: the provider or a check before dispatch refused it, and the held funds went back to the wallet. `failureCode` says why; `failureMessage` is the text to show. |

The payload carries the fields `GET /payouts/{id}` returns, with the same
values, so the event and a read of the payout agree:

```json
{
  "payoutId": 9001,
  "status": "failed",
  "walletId": 15,
  "payoutMethodId": 501,
  "amountMinor": 1250,
  "amount": "12.50",
  "currencyCode": "GBP",
  "description": "Invoice 2026-091 September commission",
  "failureCode": "payout_method_inactive",
  "failureMessage": "This payout method is not currently available. Please contact support."
}
```

`providerReference` is present once the provider has one. A payout that
becomes `cancelled` — you cancelled it, or it was rejected in review — sends
no event.

### Splits

This family is **opt-in**, like payouts: name it in an endpoint's `eventTypes`
to receive it.

| Event | Fires when |
| --- | --- |
| `SplitExecuted` | A settled payment's split posted: each payee's net share is in its wallet, the fees in your fee wallet, the remainder where the rule routes it. A payment with no active rule, or nothing to divide, sends nothing. A split that fails sends nothing either; it shows on `GET /split-executions`. |

The amounts carry the names and values of the payment's split decision record,
`GET /payments/{paymentId}/split-decisions`, which explains every number:

```json
{
  "splitExecutionId": 77,
  "paymentId": "ORDER-100106",
  "splitRuleId": 42,
  "ruleVersion": 4,
  "grossAmountMinor": 10000,
  "currencyCode": "GBP",
  "shares": [
    { "payeeId": 101, "walletId": 22, "shareAmountMinor": 8800, "feeAmountMinor": 300, "netAmountMinor": 8500 }
  ],
  "totalFeeMinor": 300,
  "remainderMinor": 1200,
  "postedAt": "2026-10-20T12:00:00Z"
}
```

### Payment links

| Event | Fires when |
| --- | --- |
| `PaymentLinkCreated` | A link was created and is payable. |
| `PaymentLinkUpdated` | Its amount, title, expiry or recipient changed. |
| `PaymentLinkPaid` | Someone paid it. A multi-use link stays active with a higher `usageCount`; a single-use link moves to `paid`. |
| `PaymentLinkExpired` | It passed its expiry unpaid. |
| `PaymentLinkCancelled` | It was cancelled before payment. |
| `PaymentLinkEmailFailed` | The invitation email could not be sent. The link itself is fine — send the URL another way rather than recreating it. |

### Recurring subscriptions

These describe **payment-link** subscriptions. Billing subscriptions emit a
separate `BillingSubscription*` family on this same pipe — see the Billing
API's webhooks guide. Two products, one word; subscribing to these tells you
nothing about the other.

`SubscriptionTrialWillEnd` is the one exception: **both** products emit it,
because the billing family has no trial-ending event of its own. Its payload
is therefore one of two shapes — the payment-link one below, or a billing
subscription's smaller `{source, subscriptionId, contractId, trialEnd,
amountMinor, currency}`.

Branch on **`source`**: every payload on this event carries it, `payment_links`
on the payment-link shape and `billing` on the billing one. It is the OpenAPI
discriminator for the `oneOf` the document publishes, so a generated client
selects the right type from it without inspecting anything else.
(`contractId` is still present on the billing shape only, and remains a valid
tell for a receiver written before `source` existed.)

| Event | Fires when |
| --- | --- |
| `SubscriptionCreated` | A recurring subscription was created on a link. |
| `SubscriptionCharged` | A term was charged and the period advanced. |
| `SubscriptionInstallmentCharged` | One monthly installment of an annual commitment was charged. |
| `SubscriptionTrialWillEnd` | A trial ends within a day — the last chance to collect a payment method. Emitted for payment-link **and** billing subscriptions, with a different payload each; see the note below the table. |
| `SubscriptionTrialEnded` | The trial ended and the subscription became active. |
| `SubscriptionCommitmentCompleted` | The final installment of an annual commitment was charged. |
| `SubscriptionCancelled` | It was cancelled, on request or because a trial ended with cancel-on-trial-end behaviour. |
| `SubscriptionPastDue` | A charge failed on its last permitted attempt. |
| `SubscriptionCouponEnded` | The subscription's coupon stopped discounting its terms: `exhausted`, `stopped` or `replaced` (see below). |

#### Subscription coupons

A coupon attached to a subscription discounts its terms for its duration
(`once`, `repeating` for N terms, or `forever`). `SubscriptionCharged` and
`SubscriptionInstallmentCharged` then also carry:

| Key | Meaning |
| --- | --- |
| `grossAmountMinor` | The term's price before the coupon. |
| `discountMinor` | What the coupon took off this term; `0` without one. |
| `amountMinor` | What the term was charged before tax: `grossAmountMinor` less `discountMinor`. `0` for a term the coupon paid in full — no payment is made for it. |
| `coupon` | `{code, duration, periodsRemaining}` of the coupon that discounted the term (`periodsRemaining` is `null` for a coupon that lasts forever), or `null`. |

`SubscriptionCouponEnded` carries `{source, subscriptionId, couponId, code,
campaignId, duration, periodsUsed, reason, endedReasonDetail, endedAt}`.
`reason` is `exhausted` (its terms are used up), `stopped` (by you, by Fynex,
or because the subscription was cancelled — `endedReasonDetail` is then
`subscription_cancelled`) or `replaced` (another coupon took its place). A
coupon given back unused when its subscription is cancelled sends no event.

### Payee verification

| Event | Fires when |
| --- | --- |
| `KYBVerificationStarted` | A verification was opened for a payee. |
| `KYBVerificationPending` | Documents are with the provider, under automated review. |
| `KYBVerificationManualReview` | The provider escalated to a human reviewer. |
| `KYBVerificationApproved` | The payee passed — this is what unlocks payouts to them. |
| `KYBVerificationRejected` | The payee failed. `reviewRejectType` separates a retryable request for documents from a final refusal. |
| `KYBVerificationLevelChanged` | The verification level changed, usually a raised limit tier. |

### Marketplace vendors

Delivered to the **platform** that onboarded the vendor (see the [Vendors
guide](/payments-api/v2/docs/vendors.md)). This family is **opt-in**: an
endpoint receives it only when its `eventTypes` names the events you want,
so adding it to your integration is a deliberate step and existing endpoints
never start receiving vendor events unasked. The payload is the vendor's
capability view -- `payoutsEnabled`, `verification`, `payoutAllowance` --
never the vendor's personal details.

| Event | Fires when |
| --- | --- |
| `MarketplaceVendorActivated` | Screening passed and `payoutsEnabled` became true: the vendor's payee can be a split or payout target, under the cumulative limit. |
| `MarketplaceVendorVerificationRequired` | The vendor must complete a verification step. `actionUrl` / `actionExpiresAt` are present when a link was issued; otherwise fetch one with `POST /vendors/{vendor_id}/verification-link`. |
| `MarketplaceVendorVerificationInProgress` | The vendor submitted; the provider is reviewing. |
| `MarketplaceVendorVerificationPendingReview` | The case is with a human reviewer. |
| `MarketplaceVendorVerificationComplete` | Verification passed; the cumulative limit no longer applies. |
| `MarketplaceVendorBlocked` | Refused at screening or verification, or blocked manually. No funds can reach the vendor. |
| `MarketplaceVendorPayoutAllowanceChanged` | `payoutAllowance.limitMinor`, `enforcement` or `unlimited` changed. |

### Commerce

| Event | Fires when |
| --- | --- |
| `PromoCodeRedeemed` | A promotion code was redeemed. The context ids say what against; the ones that do not apply are null. |
| `TaxLocationResolved` | A checkout's tax jurisdiction was resolved and the evidence stored, so you can archive the trail beside your own records. |

### Promotions

Coupons and promotional credit (the coupon wallet) are reported on three
events, all on the default subscription. A payment that used a promotion and
still took cash also carries the optional `promotion` object on its
`PaymentCompleted` payload — the same breakdown `GET
/payments/{payment_id}` returns (amounts before and after, who funded what,
`subsidyStatus`); it is absent for a payment without a promotion.

**`PromoCodeRedeemed`** fires when a code's discount is applied to a funded
order. Next to the original keys (`promoCodeId`, `redemptionId`,
`sellerCustomerId`, `paymentLinkId`, `subscriptionId`, `paymentId`,
`discountMinor`, all unchanged) it carries:

| Key | Meaning |
| --- | --- |
| `code` | The code's text. |
| `campaignId` | The promotion campaign the code belongs to; `null` for a standalone promo code. |
| `fundingParty` | Who pays the discount: `seller` (it comes off your proceeds) or `platform` (Fynex pays it to you). |
| `currency` | ISO 4217 currency of `discountMinor` and `creditsMinor`; `null` when the redemption was not priced in one (a subscription promo). |
| `creditsMinor` | Promotional credit the buyer spent on the same order; `0` when none. |

```json
{
  "promoCodeId": 12,
  "redemptionId": 3391,
  "sellerCustomerId": null,
  "paymentLinkId": 4021,
  "subscriptionId": null,
  "paymentId": 88213,
  "discountMinor": 1000,
  "code": "SPRING25",
  "campaignId": "6f739c44-b75f-4f30-bf6f-1dc67cae08ef",
  "fundingParty": "seller",
  "currency": "EUR",
  "creditsMinor": 0
}
```

**`PaymentPromotionCompleted`** fires once per order when a coupon and
promotional credit covered the **whole** total, so the buyer paid nothing and
no provider was involved. `PaymentCompleted` is not sent for such an order:
that event describes a charge, and its `amountMinor` is always positive. Treat
`PaymentPromotionCompleted` as the order's completion and fulfil on it; it is
deduplicated per payment, so a retry repeats the same `eventId`.

```json
{
  "genericPaymentId": 88214,
  "reservationId": "0b7f4c1e-8d2a-4c55-9a51-3f0f6f2a9c10",
  "campaignId": "6f739c44-b75f-4f30-bf6f-1dc67cae08ef",
  "currency": "EUR",
  "subtotalMinor": 4999,
  "couponDiscountMinor": 1000,
  "couponFundingParty": "seller",
  "taxMinor": 0,
  "totalMinor": 3999,
  "creditsMinor": 3999,
  "promotionFundedMinor": 4999,
  "sellerEntitlementMinor": 3999,
  "fundedAt": "2026-10-01T09:30:12Z"
}
```

`totalMinor` is what was due after the coupon (tax included) and
`creditsMinor` always equals it: the buyer paid nothing. `promotionFundedMinor`
is the value promotion delivered (coupon discount plus credit).
`sellerEntitlementMinor` is what you are owed for the order before fees — the
platform-funded part, which Fynex pays into your balance; a seller-funded
coupon is your own discount and is not owed back. `campaignId` and
`couponFundingParty` are `null` when only credit paid.

**`PaymentPromotionCancelled`** fires once per order when such an order is
cancelled — by you, through `POST /payments/{payment_id}/cancel-promotion`
(a refund of it answers `409`: there is no cash to return), or by Fynex
support. The buyer's credit is restored and the coupon given back where its
terms allow; the payment ends `refunded`, and the payment link it paid is not
re-opened.

```json
{
  "paymentId": 88214,
  "externalOrderRef": "ORDER-100106",
  "couponDiscountMinor": 1000,
  "creditsMinor": 3999,
  "currency": "EUR",
  "campaignId": "6f739c44-b75f-4f30-bf6f-1dc67cae08ef",
  "code": "SPRING25",
  "cancelledAt": "2026-10-02T14:05:00Z",
  "reason": "customer changed their mind"
}
```

`reason` is the reason you gave when you cancelled the order yourself; when
Fynex support cancelled it, it is always `"cancelled by Fynex"` (the support
note stays internal).

## The delivery body

Whatever fired, the body is an envelope with the event's payload nested inside
it:

```json
{
  "eventId": 918204,
  "eventType": "PaymentCompleted",
  "sellerAccountUuid": "6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55",
  "occurredAt": "2026-08-20T14:02:11Z",
  "payload": { }
}
```

`eventId` is the delivery's stable identity — deduplicate on it, not on the
payload. `occurredAt` is when the state changed, not when delivery was
attempted: a retry repeats the original value. Per-event payload fields are in
the OpenAPI document.

> [!IMPORTANT]
> Money in these payloads is in **integer minor units** (`"amountMinor": 4999`
> is €49.99), including on `PaymentCompleted` — even though the checkout
> request that created the payment takes major units. Read the field name.

## Verifying the signature

Every outbound webhook request carries two headers:

| Header | Format | Example |
|---|---|---|
| `X-Fynex-Signature` | `sha256=<hex>` | `sha256=27c6f9c3...` |
| `X-Fynex-Timestamp` | unix-seconds (decimal string) | `1747654272` |

### Algorithm

1. Read the raw request body **before** any JSON parsing — the signature is computed over the exact bytes Fynex sent.
2. Build the signed string: `<X-Fynex-Timestamp> + "." + <raw-body>` (no whitespace between the parts).
3. Compute `HMAC-SHA256(secret, signed-string)` using your stored `secretKey` as the key.
4. Hex-encode the digest (lowercase) and compare it to the value after `sha256=` in `X-Fynex-Signature` using a constant-time comparison.
5. Reject the event if the timestamp is more than 5 minutes from your server's clock. This blocks replay attacks where an attacker captured a valid signature and resends it later.

### Why timestamp + body, not just body?

Signing the timestamp alongside the body means a captured signature cannot be replayed under a different timestamp — the HMAC binds the two together. If your verifier accepts a signature without also checking the timestamp's freshness, an attacker who once intercepted a valid event can resend it indefinitely.

### Node.js

```js
import crypto from 'node:crypto';

export function verifyFynexWebhook(req, secret) {
  const signatureHeader = req.headers['x-fynex-signature'] || '';
  const timestamp = req.headers['x-fynex-timestamp'] || '';
  const rawBody = req.rawBody; // express.raw() / fastify rawBody / etc.

  // 1. Freshness — reject anything older than 5 minutes.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) {
    return false;
  }

  // 2. Re-compute the HMAC.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + rawBody)
    .digest('hex');
  const signature = signatureHeader.startsWith('sha256=')
    ? signatureHeader.slice('sha256='.length)
    : signatureHeader;

  // 3. Constant-time comparison.
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Python

```python
import hmac, hashlib, time

def verify_fynex_webhook(headers, raw_body: bytes, secret: str) -> bool:
    sig = headers.get("X-Fynex-Signature", "")
    ts = headers.get("X-Fynex-Timestamp", "")

    # 1. Freshness — reject events older than 5 minutes.
    try:
        if abs(int(time.time()) - int(ts)) > 300:
            return False
    except ValueError:
        return False

    # 2. Re-compute the HMAC.
    signed_string = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest()
    received = sig[len("sha256="):] if sig.startswith("sha256=") else sig

    # 3. Constant-time comparison.
    return hmac.compare_digest(received, expected)
```

### PHP

```php
<?php

/**
 * @param array<string,string> $headers  Case-sensitive header names as Fynex sends them.
 * @param string $rawBody                file_get_contents('php://input') — never the parsed array.
 */
function verify_fynex_webhook(array $headers, string $rawBody, string $secret): bool
{
    $sig = $headers['X-Fynex-Signature'] ?? '';
    $ts  = $headers['X-Fynex-Timestamp'] ?? '';

    // 1. Freshness — reject events older than 5 minutes.
    if (!ctype_digit(ltrim($ts, '-')) || abs(time() - (int) $ts) > 300) {
        return false;
    }

    // 2. Re-compute the HMAC.
    $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
    $received = str_starts_with($sig, 'sha256=') ? substr($sig, strlen('sha256=')) : $sig;

    // 3. Constant-time comparison.
    return hash_equals($expected, $received);
}
```

### curl smoke-test

You can replay a captured event against your local verifier:

```bash
curl -sS -X POST "http://localhost:3000/webhooks/fynex" \
  -H "Content-Type: application/json" \
  -H "X-Fynex-Timestamp: 1747654272" \
  -H "X-Fynex-Signature: sha256=27c6f9c3..." \
  --data-binary '{"eventId":42,"eventType":"PaymentCompleted","sellerAccountUuid":"6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55","occurredAt":"2026-05-19T10:11:12Z","payload":{}}'
```

The exact signature for that body and timestamp depends on your secret — generate it with the Node or Python snippet above.

## Retry and delivery semantics

- Each event is delivered to every active webhook URL on your seller account.
- A delivery is considered successful only on a `200 OK` response from your endpoint within 10 seconds.
- Failed deliveries retry up to **3 attempts total**. After the third failure the delivery is marked `failed` and not retried automatically.
- Acknowledge fast (200 in <2s) and process asynchronously — long-running handlers risk hitting the 10s timeout.

## Reading events without an endpoint

Every event is also recorded on your account, whether or not a webhook URL is registered, and `GET /events` lists them in the same envelope a delivery carries. Use it when you cannot receive webhooks (a coding agent, a script behind a firewall), or to catch up on deliveries that exhausted their retries.

```bash
curl -sS "$FYNEX_API/events?occurredFrom=2026-10-15T00:00:00Z&eventType=PaymentCompleted,PaymentRefunded" \
  -H "Authorization: Bearer $FYNEX_TOKEN"
```

The list is oldest first. Pass `nextCursor` back as `cursor` while `hasMore` is true; `nextCursor` is returned on the last page too, so a stored cursor later yields only newer events. An event appears a few seconds after it is recorded. `eventId` is the same id a delivery carries — deduplicate on it if you read both.

## See also

- **[Polling & SSE guide](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — How to know when a payment, payout, or refund changes state — without webhooks.
