# TenkiPay E-commerce Checkout Integration Guide

Version: Merchant API v1 / HMAC v2
Production origin: `https://me.tenkipay.com`
Merchant API base path: `/api/v1/merchant`

This guide implements a hosted wallet checkout for an e-commerce website. The merchant owns the cart and order. TenkiPay owns payment authentication, QR approval, wallet movement, and payment status.

## Integration outcome

The customer clicks **Pay with TenkiPay**, completes payment on the TenkiPay-hosted page, and returns to the merchant. The merchant fulfils the order only after a signed server-to-server verification reports a successful payment.

## Security boundary

- Browser: sends only the merchant's local order ID to the merchant backend.
- Merchant backend: loads the order, recalculates the amount, signs TenkiPay API requests, and stores payment state.
- TenkiPay hosted checkout: collects the customer's TenkiPay login or receives approval from the signed-in mobile app.
- Webhook endpoint: verifies the TenkiPay webhook signature before queueing work.
- Merchant worker: retrieves the checkout session from TenkiPay and marks the order paid once.

Never expose `sk_test_...`, `sk_live_...`, or a webhook secret in HTML, browser JavaScript, a mobile app, logs, screenshots, source control, or support messages.

## End-to-end flow

1. The storefront posts `order_id` to the merchant backend using the merchant application's normal authentication and CSRF protection.
2. The backend loads the order for the signed-in customer and recalculates product, discount, tax, and delivery totals from trusted database records.
3. The backend serializes the checkout payload once, signs the exact bytes with HMAC-SHA256 v2, and creates a session with a persisted `Idempotency-Key`.
4. The backend stores TenkiPay `session_id` against the local order and redirects the browser with HTTP `303` to `checkout_url`.
5. The customer signs in on TenkiPay or scans the checkout QR with the signed-in TenkiPay app.
6. TenkiPay sends `checkout.payment_completed` to the configured HTTPS webhook.
7. The merchant verifies the raw webhook body, timestamp, and HMAC before acknowledging it.
8. A worker retrieves the session using the signed merchant API.
9. The worker confirms `status=ACSC`, the merchant reference, exact amount, currency, and environment.
10. A database transaction changes the order from unpaid to paid exactly once and records the TenkiPay references.
11. The order is fulfilled. The return page displays the current server-side order state.

Never fulfil from the success redirect, query parameters, browser message, QR screen, or an unverified webhook body.

## 1. Developer Center setup

Create a developer app in the TenkiPay Business Portal and issue a sandbox key with:

- `checkout.sessions:write`
- `checkout.sessions:read`

Configure a separate sandbox webhook endpoint and store its `whsec_test_...` secret. Use different URLs, keys, and webhook secrets for sandbox and live.

Recommended server environment variables:

```dotenv
TENKIPAY_BASE_URL=https://me.tenkipay.com
TENKIPAY_PUBLIC_KEY=pk_test_REPLACE_ME
TENKIPAY_SECRET_KEY=sk_test_REPLACE_ME
TENKIPAY_WEBHOOK_SECRET=whsec_test_REPLACE_ME
TENKIPAY_ENVIRONMENT=test
STORE_URL=https://shop.example.com
```

## 2. Storefront payment button

The storefront does not call TenkiPay directly. It posts a local order ID to the merchant backend. Use the CSRF syntax provided by your framework.

```html
<form method="POST" action="/payments/tenkipay">
  <input type="hidden" name="_csrf" value="SERVER_GENERATED_CSRF_TOKEN">
  <input type="hidden" name="order_id" value="ORDER-1001">
  <button type="submit">Pay with TenkiPay</button>
</form>
```

Do not include `amount`, `currency`, `success_url`, or API credentials in this form. A customer can edit every browser field.

## 3. HMAC v2 request signing

Every merchant API request requires:

```text
X-TenkiPay-Key: pk_test_...
X-TenkiPay-Timestamp: 2026-08-05T12:30:45.000Z
X-TenkiPay-Signature: lowercase_hex_hmac_sha256
```

Write endpoints also require an `Idempotency-Key`.

The canonical string is:

```text
{timestamp}
{UPPERCASE_METHOD}
{/complete/api/path}
{exact_raw_body}
```

For GET requests with no body, the final component is empty. The path includes `/api/v1/merchant` and excludes the scheme, host, fragment, and query string.

## 4. Node.js 18+ reference implementation

Install Express and your preferred CSRF/session middleware:

```bash
npm install express
```

The TenkiPay client below is complete. The `orders` calls are the only merchant-specific database adapter methods.

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.use(express.urlencoded({ extended: false }));

const origin = process.env.TENKIPAY_BASE_URL || 'https://me.tenkipay.com';

async function signedTenkiPayRequest(method, path, payload, idempotencyKey) {
  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', process.env.TENKIPAY_SECRET_KEY)
    .update(canonical, 'utf8')
    .digest('hex');

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

  const result = await response.json();
  if (!response.ok) {
    throw new Error(`TenkiPay ${response.status}: ${result.message}`);
  }

  return result.data;
}

function createCheckout(order) {
  return signedTenkiPayRequest(
    'POST',
    '/api/v1/merchant/checkout/sessions',
    {
      merchant_reference: order.reference,
      amount: order.expectedAmount,
      currency: order.currency,
      description: `Order ${order.reference}`,
      success_url: `${process.env.STORE_URL}/orders/${order.id}/payment-return`,
      cancel_url: `${process.env.STORE_URL}/orders/${order.id}`
    },
    `checkout:${order.id}:v1`
  );
}

function retrieveCheckout(sessionId) {
  return signedTenkiPayRequest(
    'GET',
    `/api/v1/merchant/checkout/sessions/${encodeURIComponent(sessionId)}`,
    null
  );
}

app.post(
  '/payments/tenkipay',
  requireUser,
  verifyCsrf,
  async (req, res, next) => {
    try {
      const order = await orders.findPendingForCustomer(
        req.body.order_id,
        req.user.id
      );
      if (!order) return res.sendStatus(404);

      // Recalculate from trusted product, discount, tax, and delivery records.
      // Return a canonical decimal string such as "249.50", not a JS float.
      order.expectedAmount = await orders.recalculateTotal(order.id);

      const session = await createCheckout(order);
      await orders.attachCheckout(order.id, {
        sessionId: session.session_id,
        idempotencyKey: `checkout:${order.id}:v1`
      });

      return res.redirect(303, session.checkout_url);
    } catch (error) {
      return next(error);
    }
  }
);
```

`requireUser` and `verifyCsrf` represent middleware from the merchant application. Do not replace them with TenkiPay credentials.

## 5. Laravel reference client

Store credentials in server environment variables and map them through `config/services.php`. Do not call `env()` from application classes in a cached production deployment.

```php
// config/services.php
'tenkipay' => [
    'base_url' => env('TENKIPAY_BASE_URL', 'https://me.tenkipay.com'),
    'public_key' => env('TENKIPAY_PUBLIC_KEY'),
    'secret_key' => env('TENKIPAY_SECRET_KEY'),
    'environment' => env('TENKIPAY_ENVIRONMENT', 'test'),
],
```

```php
<?php

namespace App\Services;

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

final class TenkiPayClient
{
    public function createCheckout(array $payload, string $idempotencyKey): array
    {
        return $this->request(
            'POST',
            '/api/v1/merchant/checkout/sessions',
            $payload,
            $idempotencyKey
        );
    }

    public function retrieveCheckout(string $sessionId): array
    {
        return $this->request(
            'GET',
            '/api/v1/merchant/checkout/sessions/'.rawurlencode($sessionId),
            null
        );
    }

    private function request(
        string $method,
        string $path,
        ?array $payload,
        ?string $idempotencyKey = null
    ): array {
        $body = $payload === null
            ? ''
            : json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
        $timestamp = now('UTC')->format('Y-m-d\\TH:i:s.v\\Z');
        $canonical = implode("\n", [$timestamp, strtoupper($method), $path, $body]);
        $signature = hash_hmac('sha256', $canonical, config('services.tenkipay.secret_key'));

        $request = Http::baseUrl(config('services.tenkipay.base_url'))
            ->acceptJson()
            ->contentType('application/json')
            ->timeout(15)
            ->withHeaders([
                'X-TenkiPay-Key' => config('services.tenkipay.public_key'),
                'X-TenkiPay-Timestamp' => $timestamp,
                'X-TenkiPay-Signature' => $signature,
                ...($idempotencyKey ? ['Idempotency-Key' => $idempotencyKey] : []),
            ]);

        $response = $request->withBody($body, 'application/json')->send($method, $path);

        if (! $response->successful()) {
            throw new RuntimeException(
                'TenkiPay '.$response->status().': '.$response->json('message', 'Request failed.')
            );
        }

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

Use Laravel's normal `web` and `auth` middleware on the local payment route. Laravel's `web` middleware validates the form CSRF token.

## 6. Webhook verification

Configure an HTTPS webhook endpoint in Developer Center. TenkiPay sends:

```text
X-TenkiPay-Delivery: unique delivery ID
X-TenkiPay-Event: checkout.payment_completed
X-TenkiPay-Timestamp: Unix timestamp in seconds
X-TenkiPay-Signature: v1=lowercase_hex_hmac_sha256
```

The webhook canonical payload is:

```text
{unix_timestamp}.{exact_raw_request_body}
```

Node.js verification must run before JSON parsing:

```js
app.post(
  '/webhooks/tenkipay',
  express.raw({ type: 'application/json', limit: '256kb' }),
  async (req, res) => {
    const deliveryId = req.get('X-TenkiPay-Delivery');
    const timestamp = req.get('X-TenkiPay-Timestamp');
    const received = req.get('X-TenkiPay-Signature') || '';
    const rawBody = req.body.toString('utf8');
    const timestampNumber = Number(timestamp);

    if (!deliveryId || !timestamp || !/^v1=[a-f0-9]{64}$/.test(received)) {
      return res.sendStatus(401);
    }
    if (!Number.isInteger(timestampNumber) ||
        Math.abs(Date.now() / 1000 - timestampNumber) > 300) {
      return res.sendStatus(401);
    }

    const signedPayload = Buffer.concat([
      Buffer.from(`${timestamp}.`, 'utf8'),
      req.body
    ]);
    const digest = crypto
      .createHmac('sha256', process.env.TENKIPAY_WEBHOOK_SECRET)
      .update(signedPayload)
      .digest('hex');
    const expected = `v1=${digest}`;
    const valid = received.length === expected.length && crypto.timingSafeEqual(
      Buffer.from(received),
      Buffer.from(expected)
    );
    if (!valid) return res.sendStatus(401);

    const event = JSON.parse(rawBody);
    await paymentJobs.enqueueOnce(deliveryId, event);
    return res.sendStatus(204);
  }
);
```

The webhook handler should verify, persist/deduplicate, enqueue, and return quickly. Do not perform slow fulfilment inside the HTTP request.

## 7. Authoritative verification and fulfilment

The worker handling `checkout.payment_completed` retrieves the session and compares it with the local order:

```js
async function verifyAndFulfil(order, sessionId) {
  const session = await retrieveCheckout(sessionId);

  const valid =
    session.status === 'ACSC' &&
    session.merchant_reference === order.reference &&
    session.amount === order.expectedAmount &&
    session.currency === order.currency &&
    session.environment === process.env.TENKIPAY_ENVIRONMENT;

  if (!valid) {
    await alerts.raisePaymentMismatch({ order, session });
    return;
  }

  await orders.markPaidOnce(order.id, {
    provider: 'TENKIPAY',
    sessionId: session.session_id,
    paidAmount: session.amount,
    currency: session.currency,
    paidAt: session.completed_at
  });
}
```

`markPaidOnce` must use a database transaction or conditional update such as `UPDATE ... WHERE payment_status = 'UNPAID'`. Add a unique constraint for the TenkiPay session ID or provider transaction reference.

## 8. Customer return page

The `success_url` is a customer experience route, not a payment callback. It should read the local order status from the merchant backend and show one of:

- Payment confirmed
- Payment processing
- Payment unsuccessful

If still processing, poll the merchant backend briefly or tell the customer that confirmation will arrive shortly. Never change the order to paid because the browser reached this page.

## 9. Retry rules

- Timeout or `503` after creating a checkout: retry with the same idempotency key and the byte-identical body.
- `409` in progress: retry later with the same key and body.
- `409` body conflict: stop and investigate.
- `401`: create a fresh timestamp and signature; check credentials and server time.
- `422`: fix the order data or business state.
- `429` or temporary `5xx`: use bounded exponential backoff with jitter.
- Unknown create outcome: retrieve the saved session or reconcile by merchant reference before creating a new logical payment attempt.

Do not automatically generate a new idempotency key after a timeout. That can create a second checkout for the same order.

## 10. Sandbox acceptance tests

Run all tests before requesting live access:

1. Successful web login payment produces one paid order.
2. Successful QR approval produces one paid order.
3. Declined, failed, expired, and cancelled sessions leave the order unpaid.
4. Repeating checkout creation with the same key and body returns the same result.
5. Reusing the key with a changed body returns `409`.
6. Invalid and stale request signatures are rejected.
7. Invalid, stale, malformed, and replayed webhook deliveries cannot fulfil an order twice.
8. The worker rejects mismatched amount, currency, reference, or environment.
9. Webhook downtime recovers through retry or replay without duplicate fulfilment.
10. The success URL cannot mark an order paid.
11. Secrets are absent from browser source, mobile builds, logs, analytics, and source control.
12. Reconciliation can map the local order to the TenkiPay session and transaction references.

## 11. Test with the Postman collection

1. Download `https://me.tenkipay.com/developers/postman` and import it into Postman.
2. Open the collection variables and replace `public_key` and `secret_key` with sandbox credentials. The secret variable is marked as secret; do not export or share a populated collection.
3. Open **TenkiPay Checkout > Create checkout session** and change the order reference, amount, URLs, and `Idempotency-Key`.
4. Send the request. The collection pre-request script uses Postman's Web Crypto API to sign the resolved path and exact raw JSON body automatically.
5. A successful response saves `session_id` and `checkout_url` as collection variables.
6. Open the saved `checkout_url` in a browser and complete a sandbox success or failure.
7. Send **Retrieve checkout session** and confirm the final status. Only `ACSC` is paid.
8. Change the body but keep the same `Idempotency-Key` to confirm that TenkiPay returns `409`.

Postman is for development and UAT. Production e-commerce traffic must come from the merchant backend, not a Postman collection or browser script.

## 12. Go-live checklist

- Business account and developer app approved for live access.
- Only `checkout.sessions:read` and `checkout.sessions:write` granted unless more scopes are required.
- Live keys stored in a managed secret store and rotation ownership assigned.
- Production clocks synchronized and outbound HTTPS restricted to expected destinations.
- HTTPS success, cancel, and webhook domains verified.
- Webhook secret differs from API secret; test and live secrets are isolated.
- Payment mismatch, signature failure, `401`, `409`, `429`, `5xx`, and dead-webhook alerts configured.
- Order fulfilment and refund authority documented and access controlled.
- Daily payment-to-order reconciliation assigned to an operational owner.
- Incident response contacts and key revocation procedure tested.

## Merchant database fields

At minimum, retain:

```text
orders.id
orders.reference                    UNIQUE
orders.expected_amount
orders.currency
orders.payment_status
orders.tenkipay_session_id          UNIQUE, nullable
orders.tenkipay_idempotency_key     UNIQUE, nullable
orders.tenkipay_transaction_id      UNIQUE, nullable
orders.paid_at                      nullable
orders.fulfilled_at                 nullable

processed_webhooks.delivery_id      UNIQUE
processed_webhooks.event_type
processed_webhooks.received_at
processed_webhooks.processed_at     nullable
```

Keep secrets out of these records. Store only public identifiers required for support, reconciliation, and audit.

## Reference downloads

- OpenAPI 3.1: `https://me.tenkipay.com/developers/openapi`
- Postman collection: `https://me.tenkipay.com/developers/postman`
- Developer documentation: `https://me.tenkipay.com/developers/docs`
- Commented framework samples: `https://me.tenkipay.com/developers/code-samples`
- Complete integration kit: `https://me.tenkipay.com/developers/code-samples/download/all`
- Terms and Conditions: `https://me.tenkipay.com/terms`
- Privacy Policy: `https://me.tenkipay.com/privacy-policy`

The framework projects are reference implementations, not versioned TenkiPay SDK packages. The Next.js project accepts sandbox keys only and can be used to simulate the hosted checkout flow. All public examples use placeholders. A merchant must create its own developer app, keep credentials server-side, accept the applicable TenkiPay terms, and complete live-access approval before processing production payments.

For integration support, provide the developer app name, environment, merchant reference, session ID, UTC timestamp, and HTTP status. Never send a secret key or webhook secret.
