# 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,
  "sellerAccountId": 42,
  "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 reached a terminal state. `status` 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. |
| `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. |

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

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

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

## See also

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