TenkiPay
Integration Samples

Merchant API v1

Reference integrations for your backend

Runnable, commented examples for creating a hosted checkout without exposing credentials or trusting browser totals.

Reference code, not packaged SDKs

Each project implements the same HMAC-SHA256 v2 contract. Versioned language packages can be released after API stability and support commitments are formalized.

Express.js

Node.js 20+ / Express 5 · 4 files

Server-side HMAC client, trusted order lookup, checkout creation, and 303 redirect.

Download project
src/tenkipay.js
import crypto from 'node:crypto';

const CHECKOUT_PATH = '/api/v1/merchant/checkout/sessions';

export class TenkiPayError extends Error {
  constructor(message, status, details) {
    super(message);
    this.name = 'TenkiPayError';
    this.status = status;
    this.details = details;
  }
}

export class TenkiPayClient {
  constructor({ baseUrl, publicKey, secretKey, timeoutMs = 15_000 }) {
    if (!publicKey || !secretKey) {
      throw new Error('TenkiPay server credentials are not configured.');
    }

    this.baseUrl = baseUrl.replace(/\/$/, '');
    this.publicKey = publicKey;
    this.secretKey = secretKey;
    this.timeoutMs = timeoutMs;
  }

  async createCheckoutSession(payload, idempotencyKey) {
    if (!idempotencyKey) {
      throw new Error('A stable idempotency key is required for checkout creation.');
    }

    return this.#request('POST', CHECKOUT_PATH, payload, idempotencyKey);
  }

  async retrieveCheckoutSession(sessionId) {
    const path = `${CHECKOUT_PATH}/${encodeURIComponent(sessionId)}`;
    return this.#request('GET', path);
  }

  async #request(method, path, payload = null, idempotencyKey = null) {
    // Serialize exactly once: these same bytes are signed and sent to TenkiPay.
    const body = payload === null ? '' : JSON.stringify(payload);
    const timestamp = new Date().toISOString();
    const canonical = [timestamp, method.toUpperCase(), path, body].join('\n');
    const signature = crypto
      .createHmac('sha256', this.secretKey)
      .update(canonical, 'utf8')
      .digest('hex');

    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        Accept: 'application/json',
        ...(body ? { 'Content-Type': 'application/json' } : {}),
        'X-TenkiPay-Key': this.publicKey,
        'X-TenkiPay-Timestamp': timestamp,
        'X-TenkiPay-Signature': signature,
        ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
      },
      ...(body ? { body } : {}),
      signal: AbortSignal.timeout(this.timeoutMs)
    });

    const result = await response.json().catch(() => ({}));
    if (!response.ok) {
      throw new TenkiPayError(
        result.message || 'TenkiPay rejected the request.',
        response.status,
        result.errors || null
      );
    }

    return result.data;
  }
}

Go Fiber

Go 1.25 / Fiber v3 · 3 files

Typed checkout payloads with Go HMAC, bounded HTTP requests, and Fiber handlers.

Download project
main.go
package main

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/gofiber/fiber/v3"
)

const checkoutPath = "/api/v1/merchant/checkout/sessions"

type TenkiPayClient struct {
	baseURL   string
	publicKey string
	secretKey string
	http      *http.Client
}

type checkoutRequest struct {
	MerchantReference string `json:"merchant_reference"`
	Amount            string `json:"amount"`
	Currency          string `json:"currency"`
	Description       string `json:"description"`
	SuccessURL        string `json:"success_url"`
	CancelURL         string `json:"cancel_url"`
}

type checkoutSession struct {
	SessionID       string `json:"session_id"`
	PaymentIntentID string `json:"payment_intent_id"`
	CheckoutURL     string `json:"checkout_url"`
}

type apiEnvelope struct {
	Data    checkoutSession `json:"data"`
	Message string          `json:"message"`
}

func (client *TenkiPayClient) createCheckout(ctx context.Context, payload checkoutRequest, idempotencyKey string) (checkoutSession, error) {
	// Marshal once because TenkiPay verifies the exact JSON bytes sent on the wire.
	body, err := json.Marshal(payload)
	if err != nil {
		return checkoutSession{}, err
	}

	timestamp := time.Now().UTC().Format(time.RFC3339Nano)
	canonical := strings.Join([]string{timestamp, http.MethodPost, checkoutPath, string(body)}, "\n")
	mac := hmac.New(sha256.New, []byte(client.secretKey))
	_, _ = mac.Write([]byte(canonical))
	signature := hex.EncodeToString(mac.Sum(nil))

	request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+checkoutPath, bytes.NewReader(body))
	if err != nil {
		return checkoutSession{}, err
	}
	request.Header.Set("Accept", "application/json")
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("X-TenkiPay-Key", client.publicKey)
	request.Header.Set("X-TenkiPay-Timestamp", timestamp)
	request.Header.Set("X-TenkiPay-Signature", signature)
	request.Header.Set("Idempotency-Key", idempotencyKey)

	response, err := client.http.Do(request)
	if err != nil {
		return checkoutSession{}, err
	}
	defer response.Body.Close()

	responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
	if err != nil {
		return checkoutSession{}, err
	}

	var envelope apiEnvelope
	if err := json.Unmarshal(responseBody, &envelope); err != nil {
		return checkoutSession{}, fmt.Errorf("decode TenkiPay response: %w", err)
	}
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return checkoutSession{}, fmt.Errorf("TenkiPay returned %d: %s", response.StatusCode, envelope.Message)
	}

	return envelope.Data, nil
}

func main() {
	client := &TenkiPayClient{
		baseURL:   strings.TrimRight(env("TENKIPAY_BASE_URL", "https://me.tenkipay.com"), "/"),
		publicKey: os.Getenv("TENKIPAY_PUBLIC_KEY"),
		secretKey: os.Getenv("TENKIPAY_SECRET_KEY"),
		http:      &http.Client{Timeout: 15 * time.Second},
	}
	if client.publicKey == "" || client.secretKey == "" {
		panic("TenkiPay server credentials are required")
	}

	app := fiber.New()
	app.Post("/payments/tenkipay", func(c fiber.Ctx) error {
		var input struct {
			OrderID string `form:"order_id" json:"order_id"`
		}
		if err := c.Bind().Body(&input); err != nil || input.OrderID == "" {
			return fiber.NewError(fiber.StatusBadRequest, "order_id is required")
		}

		// Replace this placeholder with an authenticated, customer-scoped query.
		order, err := loadPendingOrder(input.OrderID)
		if err != nil {
			return fiber.NewError(fiber.StatusNotFound, "order not found")
		}

		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel()
		session, err := client.createCheckout(ctx, checkoutRequest{
			MerchantReference: order.Reference,
			Amount:            order.TrustedTotal,
			Currency:          "SLE",
			Description:       "Order " + order.Reference,
			SuccessURL:        env("STORE_URL", "http://localhost:3000") + "/orders/" + order.ID + "/payment-return",
			CancelURL:         env("STORE_URL", "http://localhost:3000") + "/orders/" + order.ID,
		}, "checkout:"+order.ID+":v1")
		if err != nil {
			return fiber.NewError(fiber.StatusBadGateway, "payment service is temporarily unavailable")
		}

		// Persist session IDs before redirecting so webhooks can find this order.
		if err := saveCheckoutReferences(order.ID, session); err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "could not save checkout")
		}
		return c.Redirect().Status(fiber.StatusSeeOther).To(session.CheckoutURL)
	})

	if err := app.Listen(":" + env("PORT", "3000")); err != nil {
		panic(err)
	}
}

type order struct {
	ID           string
	Reference    string
	TrustedTotal string
}

func loadPendingOrder(id string) (order, error) {
	if id == "" {
		return order{}, errors.New("missing order")
	}
	return order{ID: id, Reference: "ORDER-" + id, TrustedTotal: "249.50"}, nil
}

func saveCheckoutReferences(_ string, _ checkoutSession) error { return nil }

func env(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}

Laravel

PHP 8.3+ / Laravel 13 · 5 files

Laravel HTTP client service, authenticated controller, CSRF route, and order persistence.

Download project
app/Services/TenkiPayClient.php
<?php

namespace App\Services;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class TenkiPayClient
{
    private const CHECKOUT_PATH = '/api/v1/merchant/checkout/sessions';

    public function createCheckoutSession(array $payload, string $idempotencyKey): array
    {
        if ($idempotencyKey === '') {
            throw new RuntimeException('A stable checkout idempotency key is required.');
        }

        return $this->request('POST', self::CHECKOUT_PATH, $payload, $idempotencyKey);
    }

    public function retrieveCheckoutSession(string $sessionId): array
    {
        $path = self::CHECKOUT_PATH.'/'.rawurlencode($sessionId);

        return $this->request('GET', $path);
    }

    /**
     * Sign and send one exact JSON representation. Re-encoding the payload
     * after signing would produce different bytes and invalidate the HMAC.
     *
     * @throws RequestException
     */
    private function request(
        string $method,
        string $path,
        ?array $payload = null,
        ?string $idempotencyKey = null,
    ): array {
        $publicKey = (string) config('services.tenkipay.public_key');
        $secretKey = (string) config('services.tenkipay.secret_key');
        if ($publicKey === '' || $secretKey === '') {
            throw new RuntimeException('TenkiPay server credentials are not configured.');
        }

        $body = $payload === null
            ? ''
            : json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
        $timestamp = now('UTC')->toISOString();
        $canonical = implode("\n", [$timestamp, strtoupper($method), $path, $body]);
        $signature = hash_hmac('sha256', $canonical, $secretKey);

        $headers = [
            'X-TenkiPay-Key' => $publicKey,
            'X-TenkiPay-Timestamp' => $timestamp,
            'X-TenkiPay-Signature' => $signature,
        ];
        if ($idempotencyKey !== null) {
            $headers['Idempotency-Key'] = $idempotencyKey;
        }

        $request = Http::acceptJson()
            ->withHeaders($headers)
            ->timeout(15);

        if ($body !== '') {
            $request = $request->withBody($body, 'application/json');
        }

        $response = $request
            ->send($method, rtrim((string) config('services.tenkipay.base_url'), '/').$path)
            ->throw();

        return (array) $response->json('data');
    }
}

ASP.NET Core

.NET 10 minimal API · 6 files

Typed HttpClient integration, options binding, exact-body signing, and 303 response.

Download project
TenkiPayClient.cs
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;

namespace TenkiPay.Reference;

public sealed class TenkiPayClient(
    HttpClient httpClient,
    IOptions<TenkiPayOptions> options,
    TimeProvider timeProvider)
{
    private const string CheckoutPath = "/api/v1/merchant/checkout/sessions";
    private readonly TenkiPayOptions _options = options.Value;

    public Task<CheckoutSession> CreateCheckoutSessionAsync(
        CheckoutPayload payload,
        string idempotencyKey,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(idempotencyKey))
        {
            throw new ArgumentException("A stable idempotency key is required.", nameof(idempotencyKey));
        }

        return SendAsync<CheckoutSession>(
            HttpMethod.Post, CheckoutPath, payload, idempotencyKey, cancellationToken);
    }

    public Task<CheckoutSession> RetrieveCheckoutSessionAsync(
        string sessionId,
        CancellationToken cancellationToken = default)
    {
        var path = $"{CheckoutPath}/{Uri.EscapeDataString(sessionId)}";
        return SendAsync<CheckoutSession>(HttpMethod.Get, path, null, null, cancellationToken);
    }

    private async Task<T> SendAsync<T>(
        HttpMethod method,
        string path,
        object? payload,
        string? idempotencyKey,
        CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(_options.PublicKey) || string.IsNullOrWhiteSpace(_options.SecretKey))
        {
            throw new InvalidOperationException("TenkiPay server credentials are not configured.");
        }

        // Serialize once. This exact UTF-8 JSON is both signed and transmitted.
        var body = payload is null ? string.Empty : JsonSerializer.Serialize(payload);
        var timestamp = timeProvider.GetUtcNow().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'");
        var canonical = string.Join('\n', timestamp, method.Method.ToUpperInvariant(), path, body);
        var signatureBytes = HMACSHA256.HashData(
            Encoding.UTF8.GetBytes(_options.SecretKey),
            Encoding.UTF8.GetBytes(canonical));
        var signature = Convert.ToHexStringLower(signatureBytes);

        using var request = new HttpRequestMessage(method, path);
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        request.Headers.Add("X-TenkiPay-Key", _options.PublicKey);
        request.Headers.Add("X-TenkiPay-Timestamp", timestamp);
        request.Headers.Add("X-TenkiPay-Signature", signature);
        if (idempotencyKey is not null)
        {
            request.Headers.Add("Idempotency-Key", idempotencyKey);
        }
        if (body.Length > 0)
        {
            request.Content = new StringContent(body, Encoding.UTF8, "application/json");
        }

        using var response = await httpClient.SendAsync(request, cancellationToken);
        var envelope = await response.Content.ReadFromJsonAsync<ApiEnvelope<T>>(
            cancellationToken: cancellationToken);
        if (!response.IsSuccessStatusCode)
        {
            throw new HttpRequestException(
                envelope?.Message ?? "TenkiPay rejected the request.",
                null,
                response.StatusCode);
        }

        return envelope is null
            ? throw new InvalidOperationException("TenkiPay returned an empty response.")
            : envelope.Data;
    }
}

public sealed record CheckoutPayload(
    [property: JsonPropertyName("merchant_reference")] string MerchantReference,
    [property: JsonPropertyName("amount")] string Amount,
    [property: JsonPropertyName("currency")] string Currency,
    [property: JsonPropertyName("description")] string Description,
    [property: JsonPropertyName("success_url")] string SuccessUrl,
    [property: JsonPropertyName("cancel_url")] string CancelUrl);

public sealed record CheckoutSession(
    [property: JsonPropertyName("session_id")] string SessionId,
    [property: JsonPropertyName("payment_intent_id")] string PaymentIntentId,
    [property: JsonPropertyName("checkout_url")] string CheckoutUrl);

public sealed record ApiEnvelope<T>(
    [property: JsonPropertyName("data")] T Data,
    [property: JsonPropertyName("message")] string? Message);

FastAPI

Python 3.12+ / FastAPI · 4 files

Async httpx client, compact JSON signing, Decimal amounts, and RedirectResponse.

Download project
tenkipay.py
from __future__ import annotations

import hashlib
import hmac
import json
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote

import httpx

CHECKOUT_PATH = "/api/v1/merchant/checkout/sessions"


class TenkiPayError(RuntimeError):
    """Raised when TenkiPay cannot create or retrieve a checkout session."""


class TenkiPayClient:
    def __init__(self, base_url: str, public_key: str, secret_key: str) -> None:
        if not public_key or not secret_key:
            raise ValueError("TenkiPay server credentials are not configured.")

        self.base_url = base_url.rstrip("/")
        self.public_key = public_key
        self.secret_key = secret_key.encode("utf-8")

    async def create_checkout_session(
        self, payload: dict[str, Any], idempotency_key: str
    ) -> dict[str, Any]:
        if not idempotency_key:
            raise ValueError("A stable checkout idempotency key is required.")
        return await self._request("POST", CHECKOUT_PATH, payload, idempotency_key)

    async def retrieve_checkout_session(self, session_id: str) -> dict[str, Any]:
        path = f"{CHECKOUT_PATH}/{quote(session_id, safe='')}"
        return await self._request("GET", path)

    async def _request(
        self,
        method: str,
        path: str,
        payload: dict[str, Any] | None = None,
        idempotency_key: str | None = None,
    ) -> dict[str, Any]:
        # Compact JSON is encoded once. The exact string is both signed and sent.
        body = "" if payload is None else json.dumps(
            payload, separators=(",", ":"), ensure_ascii=False
        )
        timestamp = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
            "+00:00", "Z"
        )
        canonical = "\n".join((timestamp, method.upper(), path, body))
        signature = hmac.new(
            self.secret_key, canonical.encode("utf-8"), hashlib.sha256
        ).hexdigest()

        headers = {
            "Accept": "application/json",
            "X-TenkiPay-Key": self.public_key,
            "X-TenkiPay-Timestamp": timestamp,
            "X-TenkiPay-Signature": signature,
        }
        if body:
            headers["Content-Type"] = "application/json"
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key

        async with httpx.AsyncClient(timeout=15.0) as client:
            response = await client.request(
                method, f"{self.base_url}{path}", headers=headers, content=body or None
            )

        try:
            result = response.json()
        except ValueError as error:
            raise TenkiPayError("TenkiPay returned an unreadable response.") from error

        if response.is_error:
            raise TenkiPayError(result.get("message", "TenkiPay rejected the request."))

        return result["data"]

Jakarta Servlet

Java 21 / Servlet 6.1 · 4 files

JDK HttpClient, Jackson payloads, HmacSHA256, and a production-shaped servlet flow.

Download project
src/main/java/com/tenkipay/example/TenkiPayClient.java
package com.tenkipay.example;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;

public final class TenkiPayClient {
    private static final String CHECKOUT_PATH = "/api/v1/merchant/checkout/sessions";

    private final String baseUrl;
    private final String publicKey;
    private final String secretKey;
    private final HttpClient httpClient;
    private final ObjectMapper objectMapper;

    public TenkiPayClient(String baseUrl, String publicKey, String secretKey) {
        if (publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) {
            throw new IllegalArgumentException("TenkiPay server credentials are required.");
        }
        this.baseUrl = baseUrl.replaceAll("/+$", "");
        this.publicKey = publicKey;
        this.secretKey = secretKey;
        this.httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
        this.objectMapper = new ObjectMapper();
    }

    public CheckoutSession createCheckoutSession(
        CheckoutPayload payload,
        String idempotencyKey
    ) throws IOException, InterruptedException {
        if (idempotencyKey == null || idempotencyKey.isBlank()) {
            throw new IllegalArgumentException("A stable idempotency key is required.");
        }
        return request("POST", CHECKOUT_PATH, payload, idempotencyKey);
    }

    public CheckoutSession retrieveCheckoutSession(String sessionId)
        throws IOException, InterruptedException {
        var encodedId = URLEncoder.encode(sessionId, StandardCharsets.UTF_8).replace("+", "%20");
        return request("GET", CHECKOUT_PATH + "/" + encodedId, null, null);
    }

    private CheckoutSession request(
        String method,
        String path,
        Object payload,
        String idempotencyKey
    ) throws IOException, InterruptedException {
        // Serialize once so the HMAC and HTTP request contain identical bytes.
        var body = payload == null ? "" : objectMapper.writeValueAsString(payload);
        var timestamp = Instant.now().truncatedTo(ChronoUnit.MILLIS).toString();
        var canonical = String.join("\n", timestamp, method.toUpperCase(), path, body);
        var signature = hmacSha256(canonical, secretKey);

        var builder = HttpRequest.newBuilder(URI.create(baseUrl + path))
            .timeout(Duration.ofSeconds(15))
            .header("Accept", "application/json")
            .header("X-TenkiPay-Key", publicKey)
            .header("X-TenkiPay-Timestamp", timestamp)
            .header("X-TenkiPay-Signature", signature);
        if (idempotencyKey != null) {
            builder.header("Idempotency-Key", idempotencyKey);
        }
        if (body.isEmpty()) {
            builder.method(method, HttpRequest.BodyPublishers.noBody());
        } else {
            builder.header("Content-Type", "application/json")
                .method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
        }

        var response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
        JsonNode envelope = objectMapper.readTree(response.body());
        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            var message = envelope.path("message").asText("TenkiPay rejected the request.");
            throw new IOException("TenkiPay returned " + response.statusCode() + ": " + message);
        }
        return objectMapper.treeToValue(envelope.path("data"), CheckoutSession.class);
    }

    private static String hmacSha256(String canonical, String secretKey) {
        try {
            var mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception exception) {
            throw new IllegalStateException("Could not sign the TenkiPay request.", exception);
        }
    }

    public record CheckoutPayload(
        @JsonProperty("merchant_reference") String merchantReference,
        @JsonProperty("amount") String amount,
        @JsonProperty("currency") String currency,
        @JsonProperty("description") String description,
        @JsonProperty("success_url") String successUrl,
        @JsonProperty("cancel_url") String cancelUrl
    ) {}

    public record CheckoutSession(
        @JsonProperty("session_id") String sessionId,
        @JsonProperty("payment_intent_id") String paymentIntentId,
        @JsonProperty("checkout_url") String checkoutUrl
    ) {}
}

Next.js simulator

Next.js 16 / React 19 · 14 files

Runnable App Router sandbox tool with a server-only signing route and test-key guard.

Download project
app/api/checkout/route.ts
import crypto from 'node:crypto';
import { NextResponse } from 'next/server';
import { createSandboxCheckout, TenkiPayApiError } from '@/lib/tenkipay';

type RequestBody = {
  merchantReference?: unknown;
  amount?: unknown;
  description?: unknown;
};

const amountPattern = /^(?:0|[1-9]\d{0,9})\.\d{2}$/;
const referencePattern = /^[A-Za-z0-9._:-]{3,100}$/;

export async function POST(request: Request) {
  const body = (await request.json().catch(() => null)) as RequestBody | null;
  const merchantReference = typeof body?.merchantReference === 'string'
    ? body.merchantReference.trim()
    : '';
  const amount = typeof body?.amount === 'string' ? body.amount.trim() : '';
  const description = typeof body?.description === 'string' ? body.description.trim() : '';

  if (!referencePattern.test(merchantReference)) {
    return NextResponse.json({ message: 'Enter a valid reference (3-100 characters).' }, { status: 422 });
  }
  if (!amountPattern.test(amount) || Number(amount) <= 0) {
    return NextResponse.json({ message: 'Enter a positive amount with two decimal places.' }, { status: 422 });
  }
  if (!description || description.length > 160) {
    return NextResponse.json({ message: 'Enter a description up to 160 characters.' }, { status: 422 });
  }

  const origin = new URL(request.url).origin;
  try {
    const session = await createSandboxCheckout(
      {
        merchant_reference: merchantReference,
        amount,
        currency: 'SLE',
        description,
        success_url: `${origin}/success`,
        cancel_url: `${origin}/cancel`
      },
      // Every click is a new simulator attempt. A merchant order flow should
      // instead derive this key from its stable internal order/version.
      `simulator:${merchantReference}:${crypto.randomUUID()}`
    );
    return NextResponse.json({ data: session }, { status: 201 });
  } catch (error) {
    if (error instanceof TenkiPayApiError) {
      return NextResponse.json({ message: error.message }, { status: error.status || 502 });
    }
    return NextResponse.json({ message: 'The sandbox could not be reached.' }, { status: 502 });
  }
}

Shared production boundary

Your server owns price, credentials, and fulfilment

Load the order from your database, send a stable idempotency key, store TenkiPay references, and verify a signed webhook or server retrieval before delivering goods or services.

Review go-live controls