Skip to content
In this section

Webhooks

Outbound webhooks for payment, refund, payout, payment-link, subscription and verification lifecycle events — registration, HMAC-SHA256 signature verification, and replay-protection.

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

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

{
  "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:

{
  "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.
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.

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:

{
  "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:

{
  "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"
}
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). 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.
{
  "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.

{
  "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.

{
  "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:

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

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

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

/**
 * @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:

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

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 — How to know when a payment, payout, or refund changes state — without webhooks.