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
200counts 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
eventTypevalues. 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 unfamiliareventTypeas 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"
}
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). 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": 4999is €49.99), including onPaymentCompleted— 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
- Read the raw request body before any JSON parsing — the signature is computed over the exact bytes Fynex sent.
- Build the signed string:
<X-Fynex-Timestamp> + "." + <raw-body>(no whitespace between the parts). - Compute
HMAC-SHA256(secret, signed-string)using your storedsecretKeyas the key. - Hex-encode the digest (lowercase) and compare it to the value after
sha256=inX-Fynex-Signatureusing a constant-time comparison. - 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 OKresponse from your endpoint within 10 seconds. - Failed deliveries retry up to 3 attempts total. After the third failure the delivery is marked
failedand 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.