npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

better-auth-tamara

CI npm version MIT License

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

npm i better-auth-tamara

Peers: better-auth ^1.4, zod ^3.24 || ^4.

Upgrading from v0.1? See v0.2 breaking changes — column names changed (amountamountMinor, etc.) and a new tamaraWebhookEvent table was added. Run npx @better-auth/cli generate to 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)

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

  • AuthoriseMessage with order_status: "approved" (per-checkout merchant_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=sandbox

The 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 3000

Then 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 tamaraOrder on successful checkout (converts Tamara's decimal amount to integer *Minor on ingest)
  • Logs every verified webhook into tamaraWebhookEvent; duplicate deliveries skip persistence side-effects
  • Maps event_type → canonical status before writing; computes fully_* vs partially_* from cumulative amounts
  • Accumulates capturedAmountMinor / refundedAmountMinor across partial events
  • Powers ownership-gated GET /tamara/orders/:id and list-by-user GET /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 lint

164 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

  1. Re-run migrationsnpx @better-auth/cli generate then apply. Schema changes:

    • tamaraOrder.amountamountMinor (integer minor units)
    • tamaraOrder.capturedAmountcapturedAmountMinor (default 0, NOT NULL)
    • tamaraOrder.refundedAmountrefundedAmountMinor (default 0, NOT NULL)
    • New table tamaraWebhookEvent (required for dedup)
  2. Backfill existing rows (if upgrading a live DB): multiply old amount/capturedAmount/refundedAmount by 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.

  3. Update consumer code reading these columns — rename field references and divide by the multiplier when rendering for humans. Or use the formatTamaraAmount helper.

  4. 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 stamped authorisedAt on the approved notification, which tripped the idempotency gate and prevented the actual POST /orders/{id}/authorise call. v0.2 only stamps authorisedAt on 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 status column: v0.1 wrote "order_expired" into status. 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 logged INVALID_AMOUNT 400.

Deprecations

None — only renames. The amount/capturedAmount/refundedAmount columns are gone (not aliased); consumers must migrate.


License

MIT © Ali Dhamen. See LICENSE.