Buy-Now-Pay-Later for Better Auth: one plugin, two MENA providers (Tabby and Tamara), one typed order lifecycle.
Use Tabby, Tamara, or both. Your app talks to one canonical API for checkout, options, order reads, admin capture/refund/cancel, webhook verification, persistence, and typed client helpers.
npm i better-auth-bnplThe package ships a skill that walks an agent through the full integration: provider map, server-authoritative pricing, client wiring, schema generation, webhook registration, and provider secrets.
Install it via skills.sh:
npx skills add Genie-sa/better-auth-bnpl| Area | Included |
|---|---|
| Checkout | Hosted checkout creation for Tabby and Tamara |
| Picker | One /bnpl/options endpoint that returns available providers for the cart |
| Lifecycle | Authorise, capture, refund, cancel, void, close, and reconcile where each provider supports them |
| Webhooks | Provider-specific signature verification, canonical events, at-least-once processing with an idempotency barrier, typed handlers, admin redelivery |
| Persistence | bnplOrder and bnplWebhookEvent models with canonical status, optimistic-concurrency writes, and minor-unit money |
| Typed API | Provider ids, endpoint payloads, money, statuses, and webhook event kinds are checked at compile time |
| Provider clients | Raw Tabby and Tamara HTTP clients backed by generated OpenAPI contracts and Zod validation |
| Example app | TanStack Start + shadcn demo with user checkout, admin actions, webhook replay, provider modes, and browser verification logs |
BNPL gateways in MENA expose similar merchant workflows:
- Create a hosted-checkout session
- Optionally pre-check eligibility
- Authorise → capture → refund → cancel
- Send signed webhooks for state changes
- Provide an admin API to do the above server-side
Each provider uses different request bodies, status names, required fields, idempotency rules, region routing, and webhook signing. better-auth-bnpl keeps those differences behind one application-facing contract.
You can start with one provider and add the other later without rewriting checkout, webhook, order, or admin code.
The plugin targets the online merchant BNPL lifecycle, not every auxiliary product each provider offers.
| Capability | Tabby | Tamara |
|---|---|---|
| Countries | SA, AE, KW | SA, AE, BH, KW, OM |
| Currencies | SAR, AED, KWD | SAR, AED, KWD, BHD, OMR |
| Hosted checkout | Yes | Yes |
| Pre-check / eligibility | Heuristic plus checkout pre-scoring | Dedicated /pre-checkout/v1/eligibility |
| Authorise step | N/A | Yes; auto-handled from the approved webhook unless autoAuthorise: false |
| Capture | Full, partial, multiple | Full, partial, multiple |
| Refund | Full, partial | Full, partial |
| Cancel | Via close payment | Yes |
| Void abandoned checkout | N/A | Yes |
| Close payment | Yes | N/A |
| Webhook receive/verify | Static shared header | HS256 JWT |
| Webhook CRUD clients | Yes, raw client | Yes, raw client |
| Disputes | Tabby raw APIs only; no Better Auth route yet | N/A |
| In-store/offline flows | Not the plugin target | Raw-client/OpenAPI surface only where present |
Provider-specific operations return OPERATION_NOT_SUPPORTED when called for the wrong provider. For example, /bnpl/admin/orders/:id/void works for Tamara rows and fails cleanly for Tabby rows.
Declare the provider map once with BnplProviders(). The same plugin, client, admin routes, and webhook routes work whether the map contains one provider or both.
import { betterAuth } from "better-auth";
import {
BnplProviders,
admin,
bnpl,
checkout,
options,
orders,
webhooks,
} from "better-auth-bnpl";
import { tabby } from "better-auth-bnpl/tabby";
import { tamara } from "better-auth-bnpl/tamara";
export const bnplProviders = BnplProviders({
tabby: tabby({
secretKey: "tabby_secret_key",
merchantCode: "merchant_code",
webhookHeader: {
name: "X-Tabby-Webhook",
value: "long_random_shared_secret",
},
environment: "production",
country: "SA",
}),
tamara: tamara({
apiToken: "tamara_api_token",
notificationToken: "tamara_notification_token",
environment: "production",
}),
});
export const auth = betterAuth({
plugins: [
bnpl({
providers: bnplProviders,
persistOrders: true,
autoAuthorise: true,
captureOnAuthorise: true,
captureOnAuthoriseShippingInfo: () => ({
shippedAt: new Date().toISOString(),
shippingCompany: "Digital delivery",
}),
mapUserToBuyer: ({ user }) => ({
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
phone: user.phoneNumber,
}),
use: [
checkout({ resolveCheckout: ... }),
options(),
orders(),
webhooks({ onAuthorized, onCaptured, onRefunded }),
admin({ isAuthorized: ({ session }) => session.user.role === "admin" }),
],
}),
],
});The library exports the same bnpl() plugin and bnplClient() regardless of how many providers you use. The provider map is the only part that changes.
import { betterAuth } from "better-auth";
import {
admin,
bnpl,
checkout,
BnplProviders,
options,
orders,
webhooks,
} from "better-auth-bnpl";
import { tabby } from "better-auth-bnpl/tabby";
export const bnplProviders = BnplProviders({
tabby: tabby({
secretKey: "tabby_secret_key",
merchantCode: "merchant_code",
webhookHeader: {
name: "X-Tabby-Webhook",
value: "long_random_shared_secret",
},
environment: "production",
country: "SA",
}),
});
export const auth = betterAuth({
plugins: [
bnpl({
providers: bnplProviders,
persistOrders: true,
mapUserToBuyer: ({ user }) => ({
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
phone: user.phoneNumber,
}),
use: [checkout(), options(), orders(), webhooks(), admin({ isAuthorized: ... })],
}),
],
});import { BnplProviders } from "better-auth-bnpl";
import { tamara } from "better-auth-bnpl/tamara";
export const bnplProviders = BnplProviders({
tamara: tamara({
apiToken: "tamara_api_token",
notificationToken: "tamara_notification_token",
environment: "production",
}),
});
bnpl({
providers: bnplProviders,
})import { BnplProviders } from "better-auth-bnpl";
import { tamara } from "better-auth-bnpl/tamara";
import { tabby } from "better-auth-bnpl/tabby";
export const bnplProviders = BnplProviders({
tamara: tamara({ {} }),
tabby: tabby({ {} }),
});
bnpl({
providers: bnplProviders,
autoAuthorise: true,
persistOrders: true,
mapUserToBuyer: ...,
use: [checkout(), options(), orders(), webhooks(), admin({ ... })],
});That's the entire server-side change to add a second provider. Webhooks, admin, orders, and the picker all just work.
Provider map keys are part of the public API and must match each provider's stable provider.id:
BnplProviders({
tamara: tamara({ {} }),
tabby: tabby({ {} }),
});Do not alias built-in providers as tamaraKsa, tabbyAe, etc. Persisted orders route back to providers by provider.id, and the plugin validates this at startup so checkout, admin, and webhook dispatch stay deterministic.
Show your customer only the BNPL options that work for their cart, with provider logos and terms, no separate Tabby/Tamara branches in your UI.
POST /bnpl/options runs preCheck against every configured provider in parallel and returns a typed list. Failures degrade gracefully, so a flaky provider doesn't take down the picker. Pass email and phone when they are known at checkout so Tabby can run its documented background pre-scoring against the Checkout API; cart/product pages can omit them and still receive the local amount/country availability check.
"use client";
import { useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client";
export function BnplPicker({
priceSar,
productId,
buyer,
}: {
priceSar: number;
productId: string;
buyer?: { email?: string; phone?: string };
}) {
const [options, setOptions] = useState<Array<{
id: string; displayName: string; logoUrl?: string; tagline?: string;
available: boolean; reason?: string;
}>>([]);
useEffect(() => {
authClient.bnpl.options({
country: "SA",
amount: { amount: priceSar.toFixed(2), currency: "SAR" },
email: buyer?.email,
phone: buyer?.phone,
}).then(({ data }) => setOptions(data?.options ?? []));
}, [priceSar, buyer?.email, buyer?.phone]);
const usable = options.filter((o) => o.available);
return (
<div className="bnpl-picker">
{usable.length === 0 && <p>No BNPL options available for this order.</p>}
{usable.map((opt) => (
<button
key={opt.id}
onClick={() =>
authClient.bnpl.startCheckout({
provider: opt.id,
description: "Order",
countryCode: "SA",
shippingAddress: { line1: "X", city: "Riyadh", countryCode: "SA" },
additionalData: { productId },
})
}
>
{opt.logoUrl && <img src={opt.logoUrl} alt={opt.displayName} />}
<strong>{opt.displayName}</strong>
<span>{opt.tagline}</span>
</button>
))}
</div>
);
}startCheckout() redirects the browser to the hosted-checkout URL on success. Like every client action it resolves to { data, error } and never throws; pass { redirect: false } to skip the redirect and inspect the result yourself (analytics pre-flight, in-app webview wrappers, non-browser environments, or reading error.code).
{
options: [tamara, tabby, ...],
available: [tamara],
unavailable: [tabby],
}/bnpl/options is deliberately not cached by the plugin: eligibility is a per-customer, time-sensitive credit decision, and an in-process cache is useless in serverless anyway. Cache at the app layer instead — e.g. a React Query staleTime around authClient.bnpl.options() — so repeated cart renders don't re-hit both providers.
If you don't want a picker because your UI hardcodes one provider, use the namespaced helpers. The provider field is preset for you:
await authClient.bnpl.tabby.startCheckout({});
await authClient.bnpl.tamara.startCheckout({});For custom or future providers, use the same namespaced shape without waiting for this package to add a hardcoded shortcut:
await authClient.bnpl.provider("my-provider").startCheckout({ {} });Declare your providers map with BnplProviders() once. The client then knows which provider ids are valid for checkout, order filters, and provider-specific shortcuts.
import { bnpl, BnplProviders } from "better-auth-bnpl";
import { tamara } from "better-auth-bnpl/tamara";
import { tabby } from "better-auth-bnpl/tabby";
export const bnplProviders = BnplProviders({
tamara: tamara({ ... }),
tabby: tabby({ ... }),
});
export const auth = betterAuth({
plugins: [bnpl({ providers: bnplProviders, use: [...] })],
});
import { createAuthClient } from "better-auth/react";
import type { ProviderIdsOf } from "better-auth-bnpl";
import { bnplClient } from "better-auth-bnpl/client";
import type { bnplProviders } from "@/server/auth";
type BnplProviderId = ProviderIdsOf<typeof bnplProviders>;
export const authClient = createAuthClient({
plugins: [bnplClient<BnplProviderId>()],
});
await authClient.bnpl.startCheckout({ provider: "tabby", ... });
await authClient.bnpl.startCheckout({ provider: "tamara", ... });Webhook handlers receive the fields that match the event kind:
webhooks({
onCaptured: async ({ event }) => {
event.captureId;
event.amountMinor;
event.currency;
event.orderId;
},
onRefunded: async ({ event }) => {
event.refundId;
event.amountMinor;
},
});The same endpoints are reachable server-side through auth.api.bnpl*, fully typed when the use: [...] tuple is passed inline (the composed endpoint types flow through BnplEndpoints). auth.$Infer exposes BnplOrder, BnplOrderWithRemote, and BnplWebhookEvent for typing your own reads. At startup, init() logs the configured providers and warns if captureOnAuthorise is set without persistOrders (or without captureOnAuthoriseShippingInfo).
For code paths outside the request lifecycle — cron jobs, queue workers, scripts — createBnplClient({ providers }) wraps the same providers with a namespaced, fully-inferred API. Its authorize() returns the full BnplAuthorizeResult (autoCaptured / captureId / capturedAmountMinor / raw):
import { createBnplClient, BnplProviders } from "better-auth-bnpl";
const bnplServer = createBnplClient({ providers: bnplProviders });
await bnplServer.tamara.capture("ord_abc", {
totalAmount: { amount: "450", currency: "SAR" },
shippingInfo: { shippedAt: new Date().toISOString(), shippingCompany: "Aramex" },
});The most important security feature. Without it, a client editing totalAmount in DevTools can pay 1 SAR for a 450 SAR order, because the BNPL provider has no knowledge of your catalogue.
import { z } from "zod";
const checkoutDataSchema = z.object({
productId: z.string(),
});
checkout({
resolveCheckout: async ({ user, input, endpointContext }) => {
const { productId } = checkoutDataSchema.parse(input.additionalData);
const product = await endpointContext.context.adapter.findOne<{
sku: string; name: string; priceCents: number;
}>({ model: "product", where: [{ field: "id", value: productId }] });
if (!product) throw new Error("Product not found");
const price = (product.priceCents / 100).toFixed(2);
return {
totalAmount: { amount: price, currency: "SAR" },
taxAmount: { amount: "0", currency: "SAR" },
shippingAmount: { amount: "0", currency: "SAR" },
items: [{
referenceId: product.sku,
name: product.name,
sku: product.sku,
quantity: 1,
totalAmount: { amount: price, currency: "SAR" },
}],
};
},
}),When resolveCheckout is set, the /bnpl/checkout body becomes relaxed: money fields are optional. Any client-sent amounts are discarded; your resolver's return value is canonical. Same pattern, all providers.
- Sessions and anonymous users. A session is always required — persistence and buyer mapping need a user id, so there is no guest checkout.
checkout({ authenticatedUsersOnly: false })only permits Better Auth anonymous-plugin sessions (which still carry a user id); the default (true) rejects them withANONYMOUS_USER_NOT_ALLOWED. No session at all is always a 401. - Per-checkout redirect URLs. The
/bnpl/checkoutbody acceptssuccessUrl/failureUrl/cancelUrl(absolute http(s), ≤1024 chars). Because these become post-payment redirect targets, they are gated bycheckout({ trustedRedirectOrigins }): an origin not in the allowlist is rejected with400 INVALID_URL. The default allowlist is the origin of your Better AuthbaseURL. Precedence per URL: body field → plugin-levelsuccessUrl/failureUrl/cancelUrloption → default path. - Metadata size cap.
metadataandadditionalDataare each capped at 8KB when JSON-stringified.metadatais persisted tobnplOrder.metadata;additionalDatais forwarded to the provider. - Webhook notification URL. The default notification URL respects a custom Better Auth
basePath— it is built relative toctx.context.baseURLrather than a hardcoded/api/auth. Override withcheckout({ notificationUrlBuilder }).
All paths mount under Better Auth's API prefix (typically /api/auth).
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/bnpl/checkout |
session | Create a hosted-checkout session; body's provider selects gateway |
POST |
/bnpl/options |
optional | Return per-provider availability for (country, amount, email, phone); the picker |
GET |
/bnpl/orders |
session | List the user's BNPL orders, filterable by provider |
GET |
/bnpl/orders/:providerOrderId |
session | Get one order by the provider's order id; includes live remote payload |
GET |
/bnpl/orders/reference-id/:ref |
session | Lookup by merchant reference id |
POST |
/bnpl/webhooks/:provider |
provider sig | Single endpoint, dispatches to the right verifier by URL param |
GET |
/bnpl/admin/orders |
admin | List/search all orders (provider/status/userId filters, paginated) |
POST |
/bnpl/admin/orders/:orderId/capture |
admin | Capture, full or partial; provider auto-detected |
POST |
/bnpl/admin/orders/:orderId/refund |
admin | Refund; provider auto-detected |
POST |
/bnpl/admin/orders/:orderId/cancel |
admin | Cancel; provider auto-detected |
POST |
/bnpl/admin/orders/:orderId/authorise |
admin | Manual authorise; Tamara only, 400 for other providers |
POST |
/bnpl/admin/orders/:orderId/reconcile |
admin | Re-fetch upstream + sync local row (authoritative-remote) |
POST |
/bnpl/admin/orders/:orderId/void |
admin | Void abandoned checkout; Tamara only |
POST |
/bnpl/admin/orders/:orderId/close |
admin | Close payment + auto-refund remainder; Tabby only |
GET |
/bnpl/admin/webhook-events |
admin | List/search webhook events (status/provider/providerOrderId, paginated); needs webhooks({ redelivery }) |
POST |
/bnpl/admin/webhook-events/:id/redeliver |
admin | Re-drive a failed event through persistence + dispatch; needs webhooks({ redelivery }) |
Admin order routes resolve the order by its provider order id. The /bnpl/admin/webhook-events* routes are mounted only when webhooks() is passed a redelivery option (see Webhooks).
Provider-specific endpoints (authorise / void / close) return 400 OPERATION_NOT_SUPPORTED when called against a row whose provider doesn't support that operation.
Tabby product selection follows the canonical paymentType field: PAY_BY_INSTALMENTS and SPLIT_IN_3 select Tabby's installments, PAY_BY_LATER selects pay_later, and PAY_NOW selects pay_in_full (installments is the default when paymentType is omitted). installments is the only product key Tabby documents; there is no cross-product fallback. If the requested product has no hosted-checkout URL in Tabby's response, checkout fails rather than silently switching the payment terms.
Pass provider credentials and webhook settings directly to tabby() and tamara(). Keep secret loading in your application config; the plugin API stays a typed provider-options object.
import { BnplProviders } from "better-auth-bnpl";
import { tamara } from "better-auth-bnpl/tamara";
import { tabby } from "better-auth-bnpl/tabby";
const bnplProviders = BnplProviders({
tamara: tamara({
apiToken: "tamara_api_token",
notificationToken: "tamara_notification_token",
environment: "production",
}),
tabby: tabby({
secretKey: "tabby_secret_key",
merchantCode: "merchant_code",
webhookHeader: {
name: "X-Tabby-Webhook",
value: "long_random_shared_secret",
},
environment: "production",
country: "SA",
}),
});
bnpl({
providers: bnplProviders,
});| Option | Required | Notes |
|---|---|---|
apiToken |
yes | Bearer token from the Tamara merchant portal |
notificationToken |
yes | HMAC secret used to verify the webhook JWT |
environment |
no | "sandbox" | "production"; defaults to "sandbox" |
replayToleranceSeconds |
no | number | false. Defaults to 300. Rejects a delivery whose JWT iat is older than the window (enforced only when iat is present); false disables it |
timeoutMs |
no | Per-request upstream timeout in ms; defaults to 15000 |
baseUrl |
no | Override base URL for tests or proxies |
defaultLocale |
no | Fallback locale when a checkout omits one |
storeCode |
no | Multi-store accounts only |
Tamara's webhook body is unsigned by design. The ?tamaraToken= / Authorization: Bearer JWT authenticates the caller — its payload is only { iss, iat, exp }, no event data — so the parsed body is trusted once the token verifies. The mitigations are: HS256 pinned (no alg confusion), a timing-safe HMAC compare, exp enforced, iss pinned to "Tamara", and the replayToleranceSeconds window bounding how long a leaked token can be replayed with a forged body (query-param tokens leak into access/proxy/CDN logs). Separately, note that Tamara auto-captures orders not captured within 21 days of authorisation (per Tamara's docs) — the plugin does not enforce this; capture before then.
| Option | Required | Notes |
|---|---|---|
secretKey |
yes | Bearer token. Its type (sk_test_… vs sk_…) is what selects Tabby's test vs live environment |
merchantCode |
yes | Sent in checkout body and X-Merchant-Code header |
webhookHeader.name |
yes | The static header name configured at webhook registration time |
webhookHeader.value |
yes | Shared secret; use a long random value and rotate periodically |
country |
no | Host selector only: "SA" routes to api.tabby.sa (test and live); "AE"/"KW" route to api.tabby.ai |
environment |
no | Informational only — Tabby has no sandbox host, so this never affects routing. Test vs live is the key type |
timeoutMs |
no | Per-request upstream timeout in ms; defaults to 15000 |
baseUrl |
no | Full host override; wins over country |
preCheckBounds |
no | Per-currency amount bounds for the preCheck() heuristic |
There is no Tabby sandbox host: the environment is determined entirely by the secret-key type, and country: "SA" always routes to api.tabby.sa (including test payments). The environment option is retained for config readability but does not affect routing.
Tabby's webhook signing is static-secret per registered webhook, not body-HMAC. Use a long random TABBY_WEBHOOK_HEADER_VALUE and rotate periodically.
Every upstream call to either provider is timeout-bounded (timeoutMs, default 15000). Idempotent GETs retry up to twice with exponential backoff plus jitter on transient failures (429/502/503/504 and timeouts); money mutations (capture/refund/close) never blind-retry — a POST that times out may have succeeded upstream, so retrying it risks a double charge.
POST /bnpl/webhooks/:provider is a single endpoint where the URL parameter selects the provider's verifier. Tabby and Tamara use entirely different signing schemes (Tabby: static header secret; Tamara: HS256 JWT in ?tamaraToken= or Authorization: Bearer); the umbrella plugin keeps them isolated.
Each handler receives a narrowed event type, so runtime if (event.kind === "captured") checks are not needed. event.captureId and event.amountMinor are present and typed inside onCaptured; event.refundId is typed inside onRefunded; etc.
webhooks({
onApproved: async ({ provider, event, autoAuthoriseResult }) => {},
onAuthorized: async ({ provider, event }) => {},
onCaptured: async ({ provider, event }) => {
console.log(event.captureId, event.amountMinor, event.currency);
},
onRefunded: async ({ provider, event }) => {
console.log(event.refundId, event.amountMinor);
},
onCanceled: async ({ provider, event }) => {},
onExpired: async ({ provider, event }) => {},
onDeclined: async ({ provider, event }) => {},
onUpdated: async ({ provider, event }) => {},
onStatusChange: async ({ provider, orderId, from, to, event }) => {},
onPayload: async ({ provider, event }) => {},
tamara: {
onAuthoriseNotification: async (rawPayload, { autoAuthoriseResult }) => {
switch (autoAuthoriseResult.status) {
case "authorised": break;
case "already-authorised": break;
case "disabled": break;
case "failed": break;
}
},
},
tabby: {
onPaymentClosed: async (raw) => {},
},
}),When persistOrders: true, every verified webhook gets a bnplWebhookEvent row keyed by a provider-prefixed dedup key ({provider}:{eventType}:{capture_id|refund_id|...}). Each row moves through a lifecycle: received → processed (persistence + typed dispatch succeeded) or failed (any step threw). The row also tracks attempts, orderApplied, processedAt, and lastError.
- Failures retry, and the same event re-processes. If a handler or persistence step throws, the row is marked
failedand the endpoint returns HTTP 500. Providers retry non-200 deliveries, and the retry re-processes the same row rather than treating it as a duplicate. - Money is never double-counted. The
orderAppliedflag is the idempotency barrier: it is set the moment a capture/refund delta commits to the order row. A retry after a mid-pipeline failure skips the delta (status andrawDatastill refresh) so the cumulative amount is never applied twice. - Duplicates of already-processed events are cheap. A redelivery of a
processedevent ACKs{ received: true, duplicate: true }without re-running any handlers.
Admin capture/refund calls and captureOnAuthorise pre-seed the same provider-native dedup keys before the matching provider webhook arrives, so a merchant-initiated capture followed by order_captured / a Tabby capture delivery does not double-count capturedAmountMinor or re-fire typed handlers.
For distributed retries, pass stable operation references from your order system: merchantReferenceId on capture and merchantRefundId on refund. Tabby requires these as reference_id idempotency keys. Admin endpoints derive deterministic references from the persisted order state when omitted, but explicit references are the strongest contract when jobs may be retried across processes.
Order status advances along a forward-only transition table, because provider webhooks are at-least-once and unordered:
- Regressions are ignored. A late or retried event whose target status is not a legitimate forward transition keeps the current status and logs a warning;
rawData/updatedAtand the cumulative deltas still apply. - Tamara
order_updatednever changes status — it only revises amounts/rawData. - Currency-mismatched deltas are skipped. A capture/refund event whose currency differs from the stored order currency logs a warning and applies no delta (a ×1000 KWD delta on a ×100 SAR order would be 10× off).
- Admin reconcile is authoritative-remote and bypasses the transition guard, writing the provider's own status directly (optimistic versioning still guards against a concurrent webhook clobbering it).
Handlers run inline, before the endpoint ACKs. Providers time out slow webhook requests and retry: Tabby times out at 1 minute and retries up to 4 more times over exponential ~1–4 minute intervals, only HTTP 200 acknowledges, delivery order is not guaranteed, and duplicates happen (all per Tabby's official docs). Keep handlers fast and offload slow work to a queue. The dedup + lifecycle machinery makes such a retry safe (already-processed events ACK without re-running), but a slow handler still burns a provider retry.
Pass a redelivery option to mount two admin-gated recovery endpoints — the path once a provider exhausts its automatic retries:
webhooks({
onCaptured, onRefunded,
redelivery: {
isAuthorized: ({ session }) => session.user.role === "admin",
},
});GET /bnpl/admin/webhook-events— list/search events (filter bystatus/provider/providerOrderId, paginated).POST /bnpl/admin/webhook-events/:id/redeliver— re-derive the canonical event from the storedrawDataand re-run persistence + typed dispatch. TheorderAppliedbarrier still protects against re-applying a delta.
Tamara requires you to call POST /orders/{id}/authorise within 72h of the customer's approval signal. With autoAuthorise: true on the umbrella plugin (default), this happens automatically when an approved webhook arrives. Handler receives the outcome via autoAuthoriseResult. Tabby providers ignore this flag because they have no equivalent step.
captureOnAuthorise is intentionally opt-in for digital-goods flows and requires persistOrders: true; without a persisted row the plugin cannot know the authoritative captured amount/currency, so it logs and skips auto-capture. Tamara capture also requires shipping provenance, so provide captureOnAuthoriseShippingInfo when enabling this mode. Physical-goods flows should keep it off and use admin capture after dispatch with shippingInfo.
bnpl({
providers: bnplProviders,
persistOrders: true,
captureOnAuthorise: true,
captureOnAuthoriseShippingInfo: () => ({
shippedAt: new Date().toISOString(),
shippingCompany: "Digital delivery",
}),
use: [checkout(), orders(), webhooks()],
});Admin endpoints are mounted only when the admin() sub-plugin is in use:. They look up the order's provider from the row, then dispatch to the matching provider method. admin() also mounts GET /bnpl/admin/orders — a back-office list/search over every order (provider / status / userId filters, paginated), covered by the typed authClient.bnpl.admin.listOrders() helper. Admin rows are FULL persisted orders, rawData included (only user-facing reads strip it).
import { authClient } from "@/lib/auth-client";
await authClient.bnpl.admin.listOrders({ status: "partially_captured" });
await authClient.bnpl.admin.capture(orderId, {
totalAmount: { amount: "450", currency: "SAR" },
merchantReferenceId: `capture:${orderId}:shipment-1`,
shippingInfo: {
shippedAt: new Date().toISOString(),
shippingCompany: "Aramex",
trackingNumber: "TRK123",
},
});
await authClient.bnpl.admin.refund(orderId, {
totalAmount: { amount: "100", currency: "SAR" },
merchantRefundId: `refund:${orderId}:rma-1`,
comment: "Customer returned item",
});
await authClient.bnpl.admin.reconcile(orderId);The webhook-event helpers are mounted only when the server configures webhooks({ redelivery }) (see Admin redelivery); calling them without it returns a 404.
const { data } = await authClient.bnpl.admin.listWebhookEvents({ status: "failed" });
await authClient.bnpl.admin.redeliverWebhookEvent(eventId);persistOrders: true enables the bnplOrder and bnplWebhookEvent tables. Run npx @better-auth/cli generate to add them. Existing installs must re-run generate after upgrading to this release — it adds new columns (below).
bnplOrder: one row per checkout, discriminated by provider:
| Column | Type | Notes |
|---|---|---|
id |
string | PK |
userId |
string? | FK → user.id, onDelete: "set null". Nullable: financial records are retained for audit/compliance when a user deletes their account. An orphaned (null-owner) row never matches any owner-scoped read |
provider |
string | "tabby" | "tamara" | ... |
orderReferenceId |
string (unique) | Your reference id |
providerOrderId |
string | Provider's id (Tamara: order_id, Tabby: payment.id) |
providerCheckoutId |
string | Provider's checkout-session id |
status |
string | Canonical: new, approved, authorised, partially_captured, fully_captured, partially_refunded, fully_refunded, canceled, declined, expired, closed, updated |
amountMinor |
number | Integer minor units (halalat for SAR, fils for AED/KWD/BHD, baisa for OMR) |
currency |
string | ISO 4217: SAR, AED, KWD, BHD, OMR |
paymentType |
string? | PAY_BY_INSTALMENTS etc. |
authorisedAt, capturedAt, canceledAt |
date? | Lifecycle timestamps |
capturedAmountMinor |
number | Running total, default 0 |
refundedAmountMinor |
number | Running total, default 0 |
rawData |
string? | Last raw provider response (JSON-stringified). returned: false and stripped from all user-facing reads (GET /bnpl/orders*) — full gateway payloads (buyer PII, wire details) stay on the admin surface |
metadata |
string? | Application-level metadata |
version |
number | Optimistic-concurrency write token, default 0. Not a counter — every mutation is guarded against concurrent writers so a lost cumulative-amount update can't happen |
createdAt, updatedAt |
date |
bnplWebhookEvent: one row per verified webhook, unique dedupKey:
| Column | Type | Notes |
|---|---|---|
provider, providerOrderId |
string | Discriminator + lookup |
eventKind |
string | Canonical (captured / refunded / etc.) |
eventType |
string | Provider's native name (e.g. order_captured) |
dedupKey |
string (unique) | "{provider}:{eventType}:{id}" |
receivedAt |
date | |
rawData |
string? | JSON-stringified payload; returned: false |
status |
string | Processing lifecycle: received → processed | failed. Default received |
attempts |
number | Processing attempts, incremented on each failure. Default 0 |
orderApplied |
boolean | Whether this event's money delta has been applied to the order (the re-processing idempotency barrier). Default false |
processedAt |
date? | When processing succeeded; null while received/failed |
lastError |
string? | Last processing error — diagnostics for redelivery tooling |
Hot lookup columns declare index: true, so npx @better-auth/cli generate emits the indexes. Server-managed fields are input: false (rows are written only by the plugin's endpoints, never from client bodies). One composite the field-level attribute cannot express: a UNIQUE (provider, providerOrderId) pair — webhook lookups scope by both fields, so add that composite in your own migration.
The wire format and your BnplMoney inputs are decimal strings ("99.99"); the DB stores integer minor units (9999) per ISO 4217. KWD/BHD/OMR are 3-decimal currencies (×1000 multiplier). Use the exported helpers when reading or writing:
import { parseAmount, formatAmount } from "better-auth-bnpl";
parseAmount({ amount: "99.99", currency: "SAR" });
formatAmount(9999, "SAR");The wire amount must be a canonical decimal string — no thousands separators, sign, or scientific notation. Garbage is rejected at the schema edge (even with persistOrders off), and an amount whose minor units would exceed Number.MAX_SAFE_INTEGER throws AMOUNT_TOO_LARGE.
Every client action resolves to { data, error }; error.code narrows to BnplErrorCode. See src/core/errors.ts for the authoritative list.
import { BNPL_ERROR_CODES } from "better-auth-bnpl";
const { error } = await authClient.bnpl.startCheckout(body, { redirect: false });
if (error?.code === "CHECKOUT_REJECTED") {}| Code | Meaning |
|---|---|
AUTH_REQUIRED |
Session required |
ANONYMOUS_USER_NOT_ALLOWED |
Session is anonymous |
BUYER_MAPPER_MISSING |
mapUserToBuyer not provided |
PROVIDER_NOT_CONFIGURED |
Requested provider id is not in providers map |
CURRENCY_NOT_SUPPORTED_BY_PROVIDER |
Provider doesn't support this currency |
CHECKOUT_CREATION_FAILED |
Upstream rejected the request |
CHECKOUT_REJECTED |
Pre-scoring rejected at create time (Tabby) |
INVALID_URL |
A checkout redirect URL is not absolute http(s) or its origin isn't in trustedRedirectOrigins |
RESOLVE_CHECKOUT_INCOMPLETE |
resolveCheckout returned without the canonical money fields |
OPERATION_NOT_SUPPORTED |
Admin op called against a provider that doesn't support it |
ORDER_NOT_FOUND / ORDER_NOT_OWNED |
Order lookup / ownership |
LIST_REQUIRES_PERSISTENCE |
Listing orders needs persistOrders: true |
INVALID_AMOUNT / UNKNOWN_CURRENCY |
Money validation (INVALID_AMOUNT also covers out-of-range / non-canonical amounts) |
WEBHOOK_PROVIDER_UNKNOWN / WEBHOOK_MISSING_TOKEN / WEBHOOK_INVALID_SIGNATURE / WEBHOOK_MALFORMED_BODY / WEBHOOK_UNKNOWN_SHAPE |
Webhook verification / parsing |
WEBHOOK_HANDLER_FAILED / WEBHOOK_PERSIST_FAILED |
Webhook processing failed (row marked failed, 500 returned, provider retries) |
WEBHOOK_EVENT_NOT_FOUND / WEBHOOK_EVENT_NOT_REPLAYABLE / WEBHOOK_REDELIVERY_FAILED |
Admin redelivery tooling |
CAPTURE_FAILED / REFUND_FAILED / CANCEL_FAILED / VOID_FAILED / AUTHORISE_FAILED / RECONCILE_FAILED / CLOSE_PAYMENT_FAILED |
Provider-side rejection |
The common lifecycle is canonical, but provider rules are still enforced where they matter.
| Rule | Enforcement |
|---|---|
Tamara requires shipping_info.shipped_at and shipping_info.shipping_company on capture |
tamara.capture(...) and admin capture reject missing shipping provenance before calling Tamara |
| Tamara requires a separate authorise call after approval | The plugin calls authorise automatically on approved unless autoAuthorise: false |
Tamara cancel requires totalAmount |
Typed TamaraCancelArgs and runtime validation enforce it |
Tabby capture requires reference_id |
Typed TabbyCaptureArgs.merchantReferenceId and runtime validation enforce it |
Tabby refund requires reference_id |
Typed TabbyRefundArgs.merchantRefundId and runtime validation enforce it |
| Tabby KSA merchants use the Saudi API host | country: "SA" routes to https://api.tabby.sa (test and live); AE/KW route to https://api.tabby.ai |
| Tabby sandbox vs live is key-based | The secretKey type (sk_test_… vs sk_…) determines environment; there is no sandbox host and environment is informational only |
Tabby has no CAPTURED status |
Its lifecycle is CREATED → AUTHORIZED → CLOSED (REJECTED/EXPIRED terminal). A partial capture stays AUTHORIZED (→ partially_captured); a full capture closes the payment (→ fully_captured); the plugin disambiguates CLOSED by captured/refunded amounts (fully_captured / refund states / canceled when closed with zero captures). Webhook payloads use lowercase statuses and carry no event field |
| Provider-specific admin operations are not interchangeable | Unsupported operations return OPERATION_NOT_SUPPORTED |
MIT © Ali Dhamen.