better-auth-tamara
v0.3.0
Published
Tamara (BNPL) payment integration plugin for Better Auth — checkout, orders, webhooks, admin capture/refund/cancel, JWT HS256 signature verification
Downloads
452
Maintainers
Readme
better-auth-tamara
Tamara Buy-Now-Pay-Later as a Better Auth plugin. Checkout, order polling, JWT-verified webhooks, admin capture/refund/cancel/void, typed client actions.
No official Tamara Node SDK exists — this is built against the canonical PHP SDK and the live sandbox.
Contents
- Install
- Quickstart
- Endpoint reference
- Endpoint examples
- Configuration
- Webhooks
- Auto-authorise
- Sandbox testing
- Tamara pay-in-3 widget
- Recipes
- Server actions (
createTamaraClient) - Persisted orders table
- Error codes
- Framework setup
- Environment variables
- Testing
- Troubleshooting
- v0.2 breaking changes
- License
Install
npm i better-auth-tamaraPeers: better-auth ^1.4, zod ^3.24 || ^4.
Upgrading from v0.1? See v0.2 breaking changes — column names changed (
amount→amountMinor, etc.) and a newtamaraWebhookEventtable was added. Runnpx @better-auth/cli generateto refresh your migrations.
Quickstart
Minimum viable setup for Next.js App Router with the recommended server-authoritative pricing pattern.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { tamara, checkout, orders, webhooks, admin } from "better-auth-tamara";
export const auth = betterAuth({
// your database, providers, etc.
plugins: [
tamara({
apiToken: process.env.TAMARA_API_TOKEN!,
notificationToken: process.env.TAMARA_NOTIFICATION_TOKEN!,
environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
persistOrders: true,
autoAuthorise: true,
mapUserToConsumer: ({ user }) => {
const u = user as typeof user & { phoneNumber?: string; firstName?: string; lastName?: string };
if (!u.phoneNumber) throw new Error("Phone number required");
return {
first_name: u.firstName ?? user.name ?? "Guest",
last_name: u.lastName ?? "",
email: user.email,
phone_number: u.phoneNumber,
};
},
use: [
checkout({
successUrl: "/payment/success",
failureUrl: "/payment/failure",
cancelUrl: "/payment/cancel",
authenticatedUsersOnly: true,
// Server-authoritative pricing — DO THIS IN PROD.
// Never trust client-sent amounts.
resolveCheckout: async ({ input, user, endpointContext }) => {
const productId = (input.additionalData as { productId?: string })?.productId;
if (!productId) throw new Error("productId required");
const product = await endpointContext.context.adapter.findOne<{
id: string; name: string; sku: 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: [{
reference_id: product.sku, type: "Physical", name: product.name, sku: product.sku,
quantity: 1,
unit_price: { amount: price, currency: "SAR" },
total_amount: { amount: price, currency: "SAR" },
}],
};
},
}),
orders({ restrictToOwner: true }),
webhooks({
onAuthoriseNotification: async ({ message }) => { /* customer paid */ },
onOrderRefunded: async (evt) => { /* update state */ },
}),
admin({
isAuthorized: ({ session }) => (session.user as { role?: string }).role === "admin",
}),
],
}),
],
});// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { tamaraClient } from "better-auth-tamara/client";
export const authClient = createAuthClient({ plugins: [tamaraClient()] });// Buy button — client only sends identifier; server prices it
"use client";
import { authClient } from "@/lib/auth-client";
export function BuyButton({ productId }: { productId: string }) {
return (
<button
onClick={() =>
authClient.tamara.startCheckout({
description: "Order",
countryCode: "SA",
paymentType: "PAY_BY_INSTALMENTS",
instalments: 3,
shippingAddress: { first_name: "Ali", last_name: "D", line1: "X", city: "Riyadh", country_code: "SA" },
additionalData: { productId },
})
}
>
Pay with Tamara
</button>
);
}Finally, register https://yourdomain.com/api/auth/tamara/webhooks in the Tamara merchant portal → Settings → Webhooks for the events you care about.
Endpoint reference
All paths mount under Better Auth's API prefix (usually /api/auth).
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /tamara/checkout | session¹ | Create a checkout session |
| GET | /tamara/orders/:orderId | session | Get one order |
| GET | /tamara/orders/reference-id/:referenceId | session | Get one order by merchant reference |
| GET | /tamara/orders | session | List user's persisted orders |
| POST | /tamara/webhooks | JWT | Tamara webhook target |
| POST | /tamara/payment-options/pre-check | optional² | Eligibility pre-check |
| POST | /tamara/admin/orders/:orderId/capture | admin³ | Capture after shipping |
| POST | /tamara/admin/orders/:orderId/refund | admin³ | Simplified or full refund |
| POST | /tamara/admin/orders/:orderId/cancel | admin³ | Cancel authorised order |
| POST | /tamara/admin/checkouts/:checkoutId/void | admin³ | Void abandoned checkout |
| POST | /tamara/admin/webhooks | admin³ | Register webhook URL |
| GET | PUT | DELETE | /tamara/admin/webhooks/:webhookId | admin³ | Retrieve / update / delete webhook |
¹ Required when authenticatedUsersOnly: true.
² Only with paymentOptions() in use:. Session required if authenticatedOnly: true.
³ Only with admin({ isAuthorized }) in use:.
Endpoint examples
POST /tamara/checkout
// With resolveCheckout configured — client sends identifier, not prices
const { data } = await authClient.tamara.startCheckout({
description: "Order #4521",
countryCode: "SA",
paymentType: "PAY_BY_INSTALMENTS",
instalments: 3,
locale: "ar_SA",
shippingAddress: {
first_name: "Ali", last_name: "D", line1: "King Fahd Rd",
city: "Riyadh", country_code: "SA", phone_number: "+966544337866",
},
additionalData: { productId: "prod_kb_001" },
});// Without resolveCheckout — client must supply amounts + items
await authClient.tamara.startCheckout({
description: "Order #4521",
countryCode: "SA",
paymentType: "PAY_BY_INSTALMENTS",
instalments: 3,
totalAmount: { amount: "450", currency: "SAR" },
taxAmount: { amount: "0", currency: "SAR" },
shippingAmount: { amount: "0", currency: "SAR" },
items: [{
reference_id: "sku-kb-001", type: "Physical", name: "Keyboard", sku: "KB-001",
quantity: 1,
unit_price: { amount: "450", currency: "SAR" },
total_amount: { amount: "450", currency: "SAR" },
}],
shippingAddress: { first_name: "Ali", last_name: "D", line1: "X", city: "Riyadh", country_code: "SA" },
});startCheckout redirects to Tamara by default. Pass { redirect: false } as the second arg to get the raw response.
Response:
{
"order_id": "388dda09-4a3c-4b52-8f3b-9d9f6b7c4e21",
"checkout_id": "chk_01HX...",
"checkout_url": "https://cko-sandbox.tamara.co/checkout/chk_01HX...",
"status": "new"
}Errors:
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION | Zod rejected the body |
| 401 | AUTH_REQUIRED | authenticatedUsersOnly: true and no session |
| 401 | ANONYMOUS_USER_NOT_ALLOWED | Session is anonymous |
| 400 | PHONE_NUMBER_REQUIRED | Your mapper threw because user has no phone |
| 502 | CHECKOUT_CREATION_FAILED | Tamara returned non-2xx |
GET /tamara/orders/:orderId
const { data } = await authClient.tamara.getOrder({ orderId: "388dda09-..." });Returns the order from Tamara (passthrough). 404 → ORDER_NOT_FOUND. 403 → ORDER_NOT_OWNED when persisted and a different user's.
GET /tamara/orders/reference-id/:referenceId
const { data } = await authClient.tamara.getOrderByReferenceId({ referenceId: "invoice-9912" });GET /tamara/orders
Only works with persistOrders: true. Returns { orders, total }.
const { data } = await authClient.tamara.listOrders({ query: { limit: 20 } });POST /tamara/payment-options/pre-check
Body uses camelCase (plugin convention) — orderValue, not order_value.
const { data } = await authClient.tamara.preCheck({
country: "SA",
orderValue: { amount: "450", currency: "SAR" },
});
// { has_available_payment_options, single_checkout_enabled, available_payment_labels: [...] }POST /tamara/admin/orders/:orderId/capture
await authClient.$fetch(`/tamara/admin/orders/${orderId}/capture`, {
method: "POST",
body: {
totalAmount: { amount: "450", currency: "SAR" },
shippingInfo: {
shipped_at: new Date().toISOString(),
shipping_company: "Aramex",
tracking_number: "TRK123",
},
},
});
// { capture_id, order_id }Partial captures: call again with a smaller amount for the next shipment.
POST /tamara/admin/orders/:orderId/refund
Two modes, discriminated by type:
// Simplified — one call, one amount, required comment
await authClient.$fetch(`/tamara/admin/orders/${orderId}/refund`, {
method: "POST",
body: {
type: "simplified",
totalAmount: { amount: "100", currency: "SAR" },
comment: "Customer returned item",
merchantRefundId: "refund-001", // optional idempotency key
},
});
// { refund_id, order_id }
// Full — itemised, multiple refunds per call
await authClient.$fetch(`/tamara/admin/orders/${orderId}/refund`, {
method: "POST",
body: {
type: "full",
refunds: [
{ total_amount: { amount: "100", currency: "SAR" }, comment: "Item A" },
{ total_amount: { amount: "50", currency: "SAR" }, comment: "Shipping" },
],
},
});
// { order_id }POST /tamara/admin/orders/:orderId/cancel
await authClient.$fetch(`/tamara/admin/orders/${orderId}/cancel`, {
method: "POST",
body: { totalAmount: { amount: "450", currency: "SAR" } },
});
// { order_id, status: "canceled" }Don't send cancel_reason — Tamara's docs don't list it and sandbox rejects it.
POST /tamara/admin/orders/:orderId/authorise
Merchant-initiated authorise. Tamara's docs require the merchant to confirm receipt of the approved notification with an Authorise API call — the plugin does this automatically via webhook, but this endpoint lets you retry manually (e.g. after a webhook delivery failure or a autoAuthorise: false flow). Handles 409 already authorised as idempotent success and atomically updates the row to authorised.
await authClient.$fetch(`/tamara/admin/orders/${orderId}/authorise`, {
method: "POST",
});
// { order_id, status: "authorised", already?: boolean }Errors: AUTHORISE_FAILED (500) when Tamara returns a hard error.
POST /tamara/admin/orders/:orderId/reconcile
Re-fetches the order from Tamara's GET /orders/{id} and syncs status + capturedAmountMinor + refundedAmountMinor on the persisted row. Use this when a webhook failed to deliver, you've manually changed state via the Tamara portal, or the plugin's row has drifted from upstream.
await authClient.$fetch(`/tamara/admin/orders/${orderId}/reconcile`, {
method: "POST",
});
// { synced: true, order: { order_id, order_reference_id, status, total_amount, captured_amount?, refunded_amount? } }Errors: RECONCILE_FAILED (500) when the upstream fetch fails.
POST /tamara/admin/checkouts/:checkoutId/void
await authClient.$fetch(`/tamara/admin/checkouts/${checkoutId}/void`, {
method: "POST",
body: { orderId },
});
// { order_was_voided: true }Admin webhook CRUD
// Register
await authClient.$fetch("/tamara/admin/webhooks", {
method: "POST",
body: {
type: "order",
url: "https://yourdomain.com/api/auth/tamara/webhooks",
events: ["order_approved", "order_declined", "order_captured", "order_refunded", "order_canceled", "order_expired"],
},
});
// { webhook_id }
// Retrieve / Update / Delete
await authClient.$fetch(`/tamara/admin/webhooks/${webhookId}`);
await authClient.$fetch(`/tamara/admin/webhooks/${webhookId}`, { method: "PUT", body: { /* ... */ } });
await authClient.$fetch(`/tamara/admin/webhooks/${webhookId}`, { method: "DELETE" });Tamara's sandbox rejects order_updated at registration. Stick to the six events above.
Configuration
mapUserToConsumer (required)
Tamara needs first_name, last_name, email, phone_number on every checkout. Better Auth's core user only has name + email, so you tell the plugin where the rest live.
import type { MapUserToConsumer } from "better-auth-tamara";
// Read from additionalFields on the user row (recommended — simplest)
const mapUserToConsumer: MapUserToConsumer = ({ user }) => {
const u = user as typeof user & { phoneNumber?: string; firstName?: string; lastName?: string };
if (!u.phoneNumber) throw new Error("Phone number required");
return {
first_name: u.firstName ?? user.name ?? "Guest",
last_name: u.lastName ?? "",
email: user.email,
phone_number: u.phoneNumber,
};
};
// Or join a separate profile table
const mapUserToConsumer: MapUserToConsumer = async ({ user, endpointContext }) => {
const profile = await endpointContext.context.adapter.findOne<{
phoneNumber: string; firstName: string; lastName: string;
}>({ model: "userProfile", where: [{ field: "userId", value: user.id }] });
if (!profile) throw new Error("Profile missing");
return {
first_name: profile.firstName, last_name: profile.lastName,
email: user.email, phone_number: profile.phoneNumber,
};
};To enable additionalFields on the user:
betterAuth({
user: {
additionalFields: {
phoneNumber: { type: "string", required: false },
firstName: { type: "string", required: false },
lastName: { type: "string", required: false },
},
},
plugins: [tamara({ /* ... */ })],
});resolveCheckout (server-authoritative pricing)
The most important option for production. Without it, a client can edit totalAmount in DevTools and pay 1 SAR for a 450 SAR order — Tamara has no knowledge of your catalogue.
When resolveCheckout is configured, the plugin accepts a relaxed body (money fields optional), calls your resolver on the server, and uses its return value — any client-sent totalAmount/items is discarded.
checkout({
resolveCheckout: async ({ input, user, endpointContext }) => {
const productId = (input.additionalData as { productId?: string })?.productId;
if (!productId) throw new Error("productId required");
const product = await endpointContext.context.adapter.findOne<{
id: string; name: string; sku: 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: [{
reference_id: product.sku, type: "Physical", name: product.name, sku: product.sku,
quantity: 1,
unit_price: { amount: price, currency: "SAR" },
total_amount: { amount: price, currency: "SAR" },
}],
};
},
});Context passed to the resolver:
interface ResolveCheckoutContext {
input: CheckoutInputResolved; // client body, Zod-validated, money absent
user: User; // always present (checkout requires auth)
request?: Request;
endpointContext: GenericEndpointContext; // adapter, logger, session, etc.
}Must return:
interface ResolvedCheckoutFields {
totalAmount: TamaraMoney;
taxAmount: TamaraMoney;
shippingAmount: TamaraMoney;
items: TamaraOrderItem[];
discount?: { name: string; amount: TamaraMoney };
}Resolver throws → endpoint returns 500. Non-money fields (description, paymentType, locale, shippingAddress, additionalData) still come from the client. With persistOrders: true, the resolver's totalAmount is what ends up on the tamaraOrder row.
Multi-item cart:
resolveCheckout: async ({ user, endpointContext }) => {
const cart = await endpointContext.context.adapter.findOne<{
id: string; lineItems: Array<{ productId: string; qty: number }>;
}>({ model: "cart", where: [{ field: "userId", value: user.id }, { field: "status", value: "active" }] });
if (!cart) throw new Error("No active cart");
const products = await endpointContext.context.adapter.findMany<{
id: string; sku: string; name: string; priceCents: number;
}>({
model: "product",
where: [{ field: "id", value: cart.lineItems.map(l => l.productId), operator: "in" }],
});
const byId = new Map(products.map(p => [p.id, p]));
let totalCents = 0;
const items = cart.lineItems.map((line) => {
const p = byId.get(line.productId);
if (!p) throw new Error(`Product ${line.productId} missing`);
const lineCents = p.priceCents * line.qty;
totalCents += lineCents;
return {
reference_id: `${p.sku}-x${line.qty}`, type: "Physical" as const,
name: p.name, sku: p.sku, quantity: line.qty,
unit_price: { amount: (p.priceCents / 100).toFixed(2), currency: "SAR" },
total_amount: { amount: (lineCents / 100).toFixed(2), currency: "SAR" },
};
});
return {
totalAmount: { amount: (totalCents / 100).toFixed(2), currency: "SAR" },
taxAmount: { amount: "0", currency: "SAR" },
shippingAmount: { amount: "0", currency: "SAR" },
items,
};
},checkout() options
checkout({
successUrl: "/payment/success", // absolute or relative
failureUrl: "/payment/failure",
cancelUrl: "/payment/cancel",
notificationUrl: undefined, // defaults to /api/auth/tamara/webhooks
authenticatedUsersOnly: true, // reject unauthenticated / anonymous sessions
defaultPaymentType: "PAY_BY_INSTALMENTS", // "SPLIT_IN_3" | "PAY_BY_LATER" | "PAY_NOW"
generateOrderReferenceId: () => `my-shop-${Date.now()}`,
resolveCheckout: async (ctx) => { /* see above */ },
onCheckoutCreated: async ({ tamaraResponse, orderReferenceId, user, input }) => { /* analytics */ },
onOrderPersisted: async ({ record }) => { /* only when persistOrders: true */ },
});orders() options
orders({
restrictToOwner: true, // default — 403s if persisted order belongs to different user
});webhooks() options
webhooks({
// AuthoriseMessage — customer just completed checkout
onAuthoriseNotification: async ({ message, authoriseResult }) => { /* ... */ },
// Catch-all — runs for every verified message before the specific handler
onPayload: async (message) => { /* ... */ },
// Generic status-transition hook — fires once per canonical status change
// (e.g. `approved` → `authorised`, `partially_captured` → `fully_captured`).
// Only fires when `persistOrders: true` and only when the status actually
// changed — dedup-skipped retries do not re-fire.
onStatusChange: async ({ orderId, from, to, message }) => {
// e.g. emit a domain event, audit log, send email
},
// WebhookMessage handlers — registered via Tamara portal
onOrderApproved: async (evt) => {},
onOrderDeclined: async (evt) => {},
onOrderAuthorised: async (evt) => {},
onOrderCanceled: async (evt) => {},
onOrderExpired: async (evt) => {},
onOrderCaptured: async (evt) => {},
onOrderRefunded: async (evt) => {},
onOrderUpdated: async (evt) => {},
// Opt-in: reject tokens with iat older than N seconds
replayToleranceSeconds: 300,
});admin() options
admin({
// REQUIRED — return true to allow /tamara/admin/* calls
isAuthorized: ({ session }) =>
(session.user as { role?: string }).role === "admin",
});paymentOptions() (optional sub-plugin)
import { paymentOptions } from "better-auth-tamara";
tamara({
use: [
checkout(),
webhooks(),
paymentOptions({
defaultCountry: "SA", // fallback when client omits country
authenticatedOnly: false, // default — public for marketing pages
}),
],
});Webhooks
Tamara posts two different shapes to merchant URLs. The plugin auto-detects by presence of order_status vs event_type.
| Shape | Posted when | Distinguishing field | Handler |
|---|---|---|---|
| AuthoriseMessage | Customer completes checkout (per-checkout merchant_url.notification) | order_status | onAuthoriseNotification |
| WebhookMessage | Lifecycle event (registered webhook URL) | event_type | onOrderApproved, onOrderRefunded, etc. |
Both can carry the "customer is approved, now call authorise" signal. With autoAuthorise: true (the default), the plugin calls POST /orders/{id}/authorise for either source — AuthoriseMessage with order_status: "approved" or event_type: "order_approved". The call is idempotent across both channels (DB gate via tamaraOrder.authorisedAt plus Tamara's own "already authorised" 4xx, which is caught and treated as success). Per Tamara's docs, the authorise call MUST happen within 72 hours of the approval signal or the order expires.
Both are JWT HS256 signed with your notificationToken. Tamara delivers the token via ?tamaraToken=<jwt> or Authorization: Bearer <jwt>.
Register the same URL in both places: as merchant_url.notification on the checkout request (the plugin does this automatically), and in the Tamara portal Settings → Webhooks for the event_types you want.
Event payloads (captured from live sandbox)
AuthoriseMessage — order_status: "approved" or "declined":
{
"order_id": "388dda09-...",
"order_reference_id": "tamara_...",
"order_status": "approved",
"data": []
}data: [] is a PHP serialisation quirk for "empty object". The plugin normalises it to {}.
order_approved — Tamara's primary signal that the customer is approved. Per Tamara docs, the merchant MUST call POST /orders/{id}/authorise within 72 hours of receiving this or the order expires. With autoAuthorise: true (default) the plugin handles this automatically; onOrderApproved receives the result via the optional second arg ({ authoriseResult }):
{ "event_type": "order_approved", "order_id": "...", "order_reference_id": "...", "data": [] }webhooks({
onOrderApproved: async (event, ctx) => {
if (ctx?.authoriseResult.status === "authorised") {
// grant entitlement / send confirmation email
}
},
});order_declined — Tamara rejected credit:
{
"event_type": "order_declined",
"data": { "declined_reason": "CONSUMER_EXCEEDS_LIMIT", "declined_code": "E2001", "declined_type": "credit" }
}order_authorised — authorise call succeeded:
{ "event_type": "order_authorised", "data": [] }order_canceled:
{
"event_type": "order_canceled",
"data": { "cancel_id": "cnc_...", "canceled_amount": { "amount": "450.00", "currency": "SAR" } }
}order_expired — authorise window lapsed:
{ "event_type": "order_expired", "data": [] }order_captured — can fire multiple times; captured_amount is this event's delta, not cumulative:
{
"event_type": "order_captured",
"data": {
"capture_id": "cap_...",
"captured_amount": { "amount": "450.00", "currency": "SAR" },
"total_amount": { "amount": "450.00", "currency": "SAR" }
}
}order_refunded — can fire multiple times:
{
"event_type": "order_refunded",
"data": {
"refund_id": "rfd_...",
"capture_id": "cap_...",
"refunded_amount": { "amount": "100.00", "currency": "SAR" },
"total_amount": { "amount": "450.00", "currency": "SAR" },
"comment": "Customer returned item"
}
}authoriseResult (inside onAuthoriseNotification)
onAuthoriseNotification: async ({ message, authoriseResult }) => {
switch (authoriseResult.status) {
case "authorised": // plugin called /authorise — succeeded
case "already-authorised": // idempotent retry, DB or Tamara said "done"
case "disabled": // autoAuthorise: false — you authorise manually
case "not-approved": // order_status wasn't approved (declined, etc.)
case "failed": // plugin called /authorise — Tamara rejected
}
},Auto-authorise
Tamara requires you to call POST /orders/{id}/authorise within 72 hours of the customer's approval signal — otherwise the order expires (docs). The plugin treats both delivery channels as equivalent triggers:
AuthoriseMessagewithorder_status: "approved"(per-checkoutmerchant_url.notification)event_type: "order_approved"(registered webhook URL)
| autoAuthorise | Behaviour | Use when |
|---|---|---|
| true (default) | Plugin calls authorise immediately on either signal, then your handler. Idempotent across retries and across both channels. | Digital goods, instant-dispatch |
| false | You call tamara.authoriseOrder(orderId) or hit /tamara/admin/orders/:id/authorise after your own checks | Scarce physical inventory, fraud holds |
Only the approved signals trigger authorise. Declined / expired / cancelled never do.
The authoriseResult is surfaced to your handler:
onAuthoriseNotification(ctx)—ctx.authoriseResult(AuthoriseMessage path)onOrderApproved(event, ctx)—ctx?.authoriseResult(webhook event path)
Both share the same AuthoriseOutcome shape so consumer code is identical.
captureOnAuthorise
Digital-goods merchants (courses, subscriptions, software) grant access at authorisation time. Tamara auto-captures after 21 days if you never call capture — which leaves a long lag between "user has access" and "funds captured" on your books.
Set captureOnAuthorise: true on the main plugin and the plugin will call POST /payments/capture for the full order amount immediately after a successful authorise. Failures are logged, not thrown — the authorise already succeeded, and you can retry via /tamara/admin/orders/:id/reconcile or /admin/orders/:id/capture.
tamara({
persistOrders: true, // required — the plugin reads `amountMinor` from the row
autoAuthorise: true,
captureOnAuthorise: true,
// ...
})Physical-goods merchants should leave this off — Tamara's capture endpoint takes shipping_info which you won't have until dispatch.
Sandbox testing
All values below are sandbox only.
TAMARA_API_TOKEN=<sandbox JWT from Tamara portal — iss: "Tamara">
TAMARA_NOTIFICATION_TOKEN=<sandbox notification token>
TAMARA_ENVIRONMENT=sandboxThe sandbox API base is https://api-sandbox.tamara.co. The plugin picks the right base URL from environment.
Test card (hosted checkout page):
| Field | Value |
|---|---|
| Card | 5436 0310 3060 6378 |
| Expiry | 01/99 |
| CVV | 257 |
OTP when prompted: 123456 (or the code shown on screen).
Test phones (drive the outcome):
| Phone | Outcome |
|---|---|
| +966544337866 | Approved — full flow works |
| +966526422337 | Declined — triggers order_declined |
Fill consumer.phone_number (via mapUserToConsumer) with the desired phone to exercise that path.
Tunnel for webhook delivery (Tamara only posts to public URLs):
cloudflared tunnel --url http://localhost:3000 # free, no account
# or
ngrok http 3000Then set BETTER_AUTH_URL to the tunnel URL and register it in the Tamara portal.
Tamara pay-in-3 widget
Show "Split into 3 interest-free payments" on product pages. It's a CDN script — no npm package.
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta name="tamara-public-key" content={process.env.NEXT_PUBLIC_TAMARA_PUBLIC_KEY ?? ""} />
</head>
<body>
{children}
<Script src="https://cdn.tamara.co/widget-v2/tamara-widget.js" strategy="afterInteractive" />
</body>
</html>
);
}Always use cdn.tamara.co (production CDN), even with sandbox API credentials — the sandbox CDN 403s.
// TypeScript JSX — declare the custom element once
declare global {
namespace JSX {
interface IntrinsicElements {
"tamara-widget": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
type?: string; amount?: string;
"inline-type"?: string; "inline-position"?: string;
},
HTMLElement
>;
}
}
}
export function PriceWithWidget({ priceSar }: { priceSar: number }) {
return (
<>
<p>{priceSar} SAR</p>
<tamara-widget type="tamara-summary" amount={priceSar.toFixed(2)} />
</>
);
}Optionally gate on preCheck() so non-SA users don't see the widget:
const { data } = await authClient.tamara.preCheck({
country: "SA",
orderValue: { amount: priceSar.toFixed(2), currency: "SAR" },
});
if (data?.has_available_payment_options) {
return <tamara-widget type="tamara-summary" amount={priceSar.toFixed(2)} />;
}Recipes
Ship-then-capture on fulfilment
import { createTamaraClient } from "better-auth-tamara";
const tamara = createTamaraClient({
apiToken: process.env.TAMARA_API_TOKEN!,
environment: "production",
});
export async function onShipped(orderRef: string, tracking: string) {
const order = await tamara.getOrderByReferenceId(orderRef);
await tamara.captureOrder({
order_id: order.order_id,
total_amount: order.total_amount,
shipping_info: {
shipped_at: new Date().toISOString(),
shipping_company: "Aramex",
tracking_number: tracking,
},
});
}Inventory check before authorise
tamara({
autoAuthorise: false,
use: [
webhooks({
onAuthoriseNotification: async ({ message }) => {
if (message.order_status !== "approved") return;
if (await hasInventoryFor(message.order_reference_id)) {
await tamara.authoriseOrder(message.order_id);
} else {
await tamara.voidCheckoutSession(/* ... */);
}
},
}),
],
});Idempotent webhook handler
Use capture_id / refund_id / cancel_id as keys — webhooks can retry:
webhooks({
onOrderCaptured: async (evt) => {
const captureId = (evt.data as { capture_id: string }).capture_id;
const fresh = await redis.set(`tamara:cap:${captureId}`, "1", "NX", "EX", 86400);
if (!fresh) return; // already handled
await sendFulfilmentEmail(evt.order_reference_id);
},
});Error narrowing on the client
const { data, error } = await authClient.tamara.startCheckout(body, { redirect: false });
if (error) {
switch (error.code) {
case "PHONE_NUMBER_REQUIRED": router.push("/onboarding/phone"); break;
case "ANONYMOUS_USER_NOT_ALLOWED": router.push("/sign-in"); break;
case "CHECKOUT_CREATION_FAILED": toast.error("Payment provider unavailable"); break;
default: toast.error(error.message);
}
return;
}
window.location.href = data.checkout_url;Server actions (createTamaraClient)
For cron jobs, workers, or server components outside the HTTP lifecycle. Pure HTTP client — no Better Auth dependency.
import { createTamaraClient } from "better-auth-tamara";
const tamara = createTamaraClient({
apiToken: process.env.TAMARA_API_TOKEN!,
environment: "production",
});
// Capture after shipping
await tamara.captureOrder({
order_id: "ord_abc",
total_amount: { amount: "450", currency: "SAR" },
shipping_info: { shipped_at: new Date().toISOString(), shipping_company: "Aramex", tracking_number: "TRK123" },
});
// Simplified refund
await tamara.simplifiedRefund("ord_abc", {
total_amount: { amount: "100", currency: "SAR" },
comment: "Customer returned item",
});
// Full itemised refund
await tamara.refund({
order_id: "ord_abc",
refunds: [{ total_amount: { amount: "100", currency: "SAR" }, comment: "Item A" }],
});
// Cancel authorised-but-uncaptured order
await tamara.cancelOrder("ord_abc", { total_amount: { amount: "450", currency: "SAR" } });
// Void abandoned checkout (before authorise)
await tamara.voidCheckoutSession("chk_xyz", { order_id: "ord_abc" });
// Pre-check — raw API uses snake_case (order_value), not camelCase
const availability = await tamara.paymentOptionsPreCheck({
country: "SA",
order_value: { amount: "450", currency: "SAR" },
});
// Fetch
const order = await tamara.getOrder("ord_abc");
const byRef = await tamara.getOrderByReferenceId("my-shop-1234");
// Rename reference id
await tamara.updateReferenceId("ord_abc", { order_reference_id: "invoice-9999" });
// Authorise manually (when autoAuthorise: false)
await tamara.authoriseOrder("ord_abc");All methods throw TamaraApiError on non-2xx (carries .status and .body).
Persisted orders table
Set persistOrders: true and run npx @better-auth/cli generate to add the tamaraOrder + tamaraWebhookEvent tables. Works on SQLite, Postgres, MySQL via Kysely, Drizzle, Prisma, MongoDB — same as Better Auth itself.
tamaraOrder — one row per checkout:
| Column | Type | Notes |
|---|---|---|
| id | string | PK |
| userId | string | FK → user, cascade delete |
| orderReferenceId | string | unique — your reference |
| orderId | string? | unique — Tamara's order id |
| checkoutId | string? | Tamara's checkout session id |
| status | string | Canonical Tamara status (new, approved, authorised, partially_captured, fully_captured, partially_refunded, fully_refunded, canceled, declined, expired, updated) — event_type strings are mapped before persist |
| amountMinor | number | Total in integer minor units — see Money below |
| currency | string | 3-char ISO (SAR, AED, KWD, BHD, OMR) |
| paymentType | string? | |
| authorisedAt | date? | Set only after confirmed authorise (not on approved) |
| capturedAt | date? | Set on first capture |
| capturedAmountMinor | number | Running total across partial captures, defaults to 0 |
| canceledAt | date? | |
| refundedAmountMinor | number | Running total across partial refunds, defaults to 0 |
| rawData | string? | Last webhook payload (JSON-stringified) |
| createdAt / updatedAt | date | |
tamaraWebhookEvent — one row per verified webhook, unique on dedupKey for idempotent retries:
| Column | Type | Notes |
|---|---|---|
| id | string | PK |
| orderId | string | Tamara's order id |
| orderReferenceId | string? | |
| eventType | string | "authorise" for notification messages, else the webhook event_type |
| dedupKey | string | unique — "{eventType}:{capture_id \| refund_id \| cancel_id \| order_id}". Duplicate deliveries collide on this index |
| receivedAt | date | |
| rawData | string? | JSON-stringified payload |
With persistence on, the plugin:
- Inserts
tamaraOrderon successful checkout (converts Tamara's decimal amount to integer*Minoron ingest) - Logs every verified webhook into
tamaraWebhookEvent; duplicate deliveries skip persistence side-effects - Maps
event_type→ canonical status before writing; computesfully_*vspartially_*from cumulative amounts - Accumulates
capturedAmountMinor/refundedAmountMinoracross partial events - Powers ownership-gated
GET /tamara/orders/:idand list-by-userGET /tamara/orders
Money
Tamara's wire API exchanges decimal strings ("100.00"). The plugin stores integer minor units (halalat for SAR, fils for AED/KWD/BHD, baisa for OMR) per ISO 4217 — matching Stripe's "amounts are in a currency's smallest unit" convention. Conversion exponents:
| Currency | Minor unit | Exponent |
|---|---|---|
| SAR, AED | halalat / fils | ×100 |
| KWD, BHD, OMR | fils / baisa | ×1000 |
So a 99.99 SAR order stores as amountMinor: 9999. Use the exported helpers when reading/writing:
import { parseTamaraAmount, formatTamaraAmount } from "better-auth-tamara";
parseTamaraAmount({ amount: "99.99", currency: "SAR" }); // 9999
formatTamaraAmount(9999, "SAR"); // { amount: "99.99", currency: "SAR" }Checkout bodies and admin endpoint bodies still take Tamara's decimal wire shape — the conversion happens automatically at the plugin boundary. Only DB reads need the helpers.
Error codes
Every plugin error carries a machine-readable code:
import { TAMARA_ERROR_CODES } from "better-auth-tamara";
const { error } = await authClient.tamara.startCheckout(body, { redirect: false });
if (error?.code === "CONSUMER_MAPPER_MISSING") { /* ... */ }| Code | Meaning |
|---|---|
| CONSUMER_MAPPER_MISSING | mapUserToConsumer not provided |
| PHONE_NUMBER_REQUIRED | Mapper threw — no phone on user |
| AUTH_REQUIRED | Session required |
| ANONYMOUS_USER_NOT_ALLOWED | authenticatedUsersOnly rejected anonymous |
| ORDER_NOT_FOUND | |
| ORDER_NOT_OWNED | Persisted order belongs to different user |
| LIST_REQUIRES_PERSISTENCE | GET /tamara/orders without persistOrders: true |
| CHECKOUT_CREATION_FAILED | Tamara rejected create-checkout |
| ORDER_FETCH_FAILED | Upstream fetch failed |
| CAPTURE_FAILED / REFUND_FAILED / CANCEL_FAILED / VOID_FAILED | Admin action upstream failed |
| AUTHORISE_FAILED | /tamara/admin/orders/:id/authorise upstream failed |
| RECONCILE_FAILED | /tamara/admin/orders/:id/reconcile upstream failed |
| INVALID_AMOUNT | Checkout amount rejected by strict parser (malformed, locale-formatted, or exceeds currency precision) |
| UNKNOWN_CURRENCY | Currency outside SAR/AED/KWD/BHD/OMR |
| PRECHECK_FAILED | Pre-check upstream failed |
| WEBHOOK_MISSING_TOKEN | No ?tamaraToken or Bearer |
| WEBHOOK_INVALID_SIGNATURE | JWT check failed |
| WEBHOOK_MALFORMED_BODY | Body wasn't valid JSON |
| WEBHOOK_UNKNOWN_SHAPE | Neither order_status nor event_type |
| WEBHOOK_HANDLER_FAILED | Your handler threw |
| WEBHOOK_REGISTRATION_FAILED / _RETRIEVE_FAILED / _UPDATE_FAILED / _DELETE_FAILED | Admin webhook CRUD upstream failed |
| ADMIN_AUTHORIZATION_REQUIRED | admin() without isAuthorized |
| ADMIN_FORBIDDEN | isAuthorized returned false |
Framework setup
The plugin is framework-agnostic — mount Better Auth however you already do. A few common ones:
Next.js App Router:
// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);Next.js Pages Router:
// pages/api/auth/[...all].ts
import { toNodeHandler } from "better-auth/node";
export default toNodeHandler(auth);
export const config = { api: { bodyParser: false } };Express — register the handler before express.json() so the webhook raw body isn't mutated:
app.all("/api/auth/*", toNodeHandler(auth));
app.use(express.json());Hono:
app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));Cloudflare Workers — JWT verify uses node:crypto, so enable nodejs_compat:
// wrangler.jsonc
{ "compatibility_flags": ["nodejs_compat"] }For other frameworks (Bun, Deno, Fastify, SvelteKit, Nuxt, Remix, TanStack Start, Astro, Elysia): follow Better Auth's integration docs — there's nothing Tamara-specific to do.
Client
// React — swap "better-auth/react" for "better-auth/vue" | "/svelte" | "/solid" | "/client"
import { createAuthClient } from "better-auth/react";
import { tamaraClient } from "better-auth-tamara/client";
export const authClient = createAuthClient({ plugins: [tamaraClient()] });All frameworks get the same typed actions under authClient.tamara.*: startCheckout, getOrder, getOrderByReferenceId, listOrders, preCheck.
Environment variables
TAMARA_API_TOKEN= # Bearer token (Merchant portal → Integration)
TAMARA_NOTIFICATION_TOKEN= # HMAC secret for webhook JWT verification
TAMARA_ENVIRONMENT=sandbox # or "production"Server-side only. Never expose them to the browser.
Testing
npm test # vitest one-shot
npm run test:watch
npm run test:coverage
npm run typecheck
npm run lint164 tests across 15 files. CI runs typecheck + lint + test + build on Node 18/20/22.
Mock Tamara in your own tests via the fetch option:
import { getTestInstance } from "better-auth/test";
import { tamara, checkout, webhooks } from "better-auth-tamara";
const instance = await getTestInstance({
plugins: [
tamara({
apiToken: "test",
notificationToken: "test",
fetch: async () => new Response(JSON.stringify({ order_id: "ord_1", checkout_id: "c", checkout_url: "x", status: "new" })),
mapUserToConsumer: ({ user }) => ({
first_name: "T", last_name: "U", email: user.email, phone_number: "+966500000000",
}),
use: [checkout(), webhooks()],
}),
],
});Troubleshooting
mapUserToConsumer is required
Pass it to the main plugin — the plugin can't guess your schema.
Invalid JWT signature
notificationToken doesn't match, or a body-parser mutated the request before the plugin saw it. On Express, register toNodeHandler(auth) before express.json().
Webhook fires but nothing runs
Events like order_refunded only arrive at URLs registered in the Tamara portal. merchant_url.notification on checkout only gets AuthoriseMessage.
Auto-authorise never runs
order_status must be "approved" (or legacy "order_approved"). Declined/expired don't trigger authorise.
Order expired before capture
Tamara's authorise window is short (~30 min sandbox). If autoAuthorise: false, authorise promptly — don't wait for user action.
Cloudflare Workers: Cannot find module 'node:crypto'
Enable nodejs_compat in wrangler.jsonc.
user.phoneNumber undefined
Add it via user.additionalFields and run npx @better-auth/cli generate, or pull from a separate profile table inside your mapper.
v0.2 breaking changes
v0.2 was driven by a production-integration bug report covering stuck-in-approved orders, silent webhook double-counting, and unit-confusion on stored amounts. See the refactor commit for the full diff.
Migration checklist
Re-run migrations —
npx @better-auth/cli generatethen apply. Schema changes:tamaraOrder.amount→amountMinor(integer minor units)tamaraOrder.capturedAmount→capturedAmountMinor(default0,NOT NULL)tamaraOrder.refundedAmount→refundedAmountMinor(default0,NOT NULL)- New table
tamaraWebhookEvent(required for dedup)
Backfill existing rows (if upgrading a live DB): multiply old
amount/capturedAmount/refundedAmountby the ISO 4217 minor-unit multiplier for the row's currency (×100 for SAR/AED, ×1000 for KWD/BHD/OMR) and write to the new columns.Update consumer code reading these columns — rename field references and divide by the multiplier when rendering for humans. Or use the
formatTamaraAmounthelper.Canonical statuses — if you had code filtering on
"order_expired","order_canceled", etc., switch to the canonical enum values ("expired","canceled"). Event-type strings are no longer stored.
What's new
| Feature | Where |
|---|---|
| onStatusChange({ orderId, from, to, message }) hook | webhooks() options |
| captureOnAuthorise: boolean auto-capture | TamaraOptions |
| POST /tamara/admin/orders/:id/authorise | Admin endpoint |
| POST /tamara/admin/orders/:id/reconcile | Admin endpoint |
| Webhook dedup via tamaraWebhookEvent.dedupKey | Automatic when persistOrders: true |
| CANONICAL_STATUSES, isCanonicalStatus, eventTypeToStatus | Status module exports |
| parseTamaraAmount, formatTamaraAmount, extractMinorAmount, CURRENCY_MINOR_UNIT_EXPONENT | Money module exports |
| TamaraPluginError, isTamaraPluginError | Typed error class |
| onOrderUpdated event handler | webhooks() options |
Bug fixes
- Stuck in
approved: v0.1 stampedauthorisedAton the approved notification, which tripped the idempotency gate and prevented the actualPOST /orders/{id}/authorisecall. v0.2 only stampsauthorisedAton confirmed authorise. - Double-counted captures/refunds: v0.1 read-then-incremented without a dedup key. v0.2 uses
tamaraWebhookEvent.dedupKey(unique index on{event_type}:{capture_id|refund_id|cancel_id}) to make retries idempotent. - Event-type leak into
statuscolumn: v0.1 wrote"order_expired"intostatus. v0.2 maps to canonical"expired". - Silent amount corruption: v0.1 accepted
"1,000.00","99.99"into an integer column. v0.2 rejects malformed input with a loggedINVALID_AMOUNT400.
Deprecations
None — only renames. The amount/capturedAmount/refundedAmount columns are gone (not aliased); consumers must migrate.
License
MIT © Ali Dhamen. See LICENSE.
