# AsterPOS Ecommerce — Integration Guide

Build payments into your website or platform in under an hour. AsterPOS
Ecommerce is a payments-only API — you keep your products, cart, orders,
invoices, and customer data; we own checkout, tokenization, provider
routing, transactions, webhooks, and refunds.

This guide is provider-agnostic. Whether your assigned payment profile
routes through Valor, iPOSpays, Gear Up, or any future processor, the
API surface and code you write does not change.

> **Audience**: server-side developers integrating ecommerce checkout or
> invoice payment links into a merchant website.

---

## Table of contents

1. [Concepts](#concepts)
2. [Authentication](#authentication)
3. [Checkout Sessions](#checkout-sessions)
4. [Payment Links](#payment-links)
5. [Refunds](#refunds)
6. [Future Customer Wallet — Coming Soon](#future-customer-wallet--coming-soon)
7. [Webhooks](#webhooks)
8. [Idempotency](#idempotency)
9. [Error handling](#error-handling)
10. [Sandbox testing](#sandbox-testing)
11. [Production activation](#production-activation)

---

## Concepts

```
+----------------------+        +-----------------------+
| Your website         |        | Customer browser      |
| (orders/invoices)    |        | (any device)          |
+----------+-----------+        +-----------+-----------+
           |   POST /checkout-sessions      |
           |  ----------------------------> |
           |                                |
           |   { checkout_url }             |
           |  <---------------------------- |
           |   redirect customer            |
           |                                |
           |                  GET /checkout/{sid}
           |                                |
           |                                v
           |                    +-----------+-----------+
           |                    | pay.asterpos.com      |
           |                    | (hosted checkout)     |
           |                    +-----------+-----------+
           |                                |
           |                                |  customer pays
           |                                |
           |  payment.succeeded webhook     |
           |  <---------------------------- |
           |                                |
           |  redirect to success_url       |
           |  <---------------------------- |
+----------+-----------+                    |
| Mark order paid      |                    |
+----------------------+                    |
```

Three nouns you will work with:

* **Checkout session** — short-lived (default 30 min). Created when a
  customer is at your cart ready to pay. URL: `pay.asterpos.com/checkout/{id}`.
* **Payment link** — long-lived (default 30 days). Created for invoices,
  quotes, deposits, or any payment you send by email / SMS / QR code.
  URL: `pay.asterpos.com/pay/{id}`.
* **Webhook event** — signed POST from us to your `webhook_url` after
  every terminal state change (`payment.succeeded`, `payment.failed`,
  `payment.canceled`, `checkout.session.expired`).

You never call the processor directly. Payment Hub routes every payment
through the payment profile assigned to your merchant account.

---

## Authentication

You receive two credentials at onboarding:

| Credential        | Example                                       | Used for                                                     |
|-------------------|-----------------------------------------------|--------------------------------------------------------------|
| `publishable_key` | `pk_live_abc123...`                           | `Authorization: Bearer` on `/checkout-sessions`, `/payment-links` |
| `webhook_secret`  | `whsec_xyz...`                                | HMAC verification of inbound webhooks                        |

> Both secrets are shown **once** at creation time. Store them in a
> secrets manager. If you lose either, request a rotation from your
> AsterPOS contact — old values are invalidated immediately.

### Base URL

```
Production : https://api.asterpos.com
Sandbox    : https://sandbox-api.asterpos.com   (provided at onboarding)
```

All paths below are relative to the base URL.

### Authenticated request (cURL)

```bash
curl https://api.asterpos.com/api/asterpos-ecommerce/checkout-sessions \
  -H "Authorization: Bearer ${ASTERPOS_PUBLISHABLE_KEY}" \
  -H "Content-Type: application/json"
```

---

## Checkout Sessions

A checkout session is a single-customer single-cart payment that
expires in 30 minutes by default.

### Lifecycle

```
created → pending → paid          (customer paid)
              ↘   → failed        (declined / error)
              ↘   → canceled      (customer clicked Cancel)
created       ↘   → expired       (TTL elapsed)
```

### Create a session

`POST /api/asterpos-ecommerce/checkout-sessions`

| Field                | Type    | Required | Notes                                                  |
|----------------------|---------|----------|--------------------------------------------------------|
| `external_order_id`  | string  | yes      | Your order id. Echoed back in webhooks.                |
| `amount_cents`       | integer | yes      | Smallest currency unit. `1500` = $15.00.               |
| `currency`           | string  | no       | ISO-4217. Default `USD`.                               |
| `success_url`        | string  | yes      | Customer is sent here after a successful payment.      |
| `cancel_url`         | string  | yes      | Customer is sent here if they cancel.                  |
| `webhook_url`        | string  | yes      | We POST signed events here.                            |
| `idempotency_key`    | string  | yes      | Unique per logical request (see [Idempotency](#idempotency)). |
| `customer_email`     | string  | no       | Pre-fills the receipt.                                 |
| `customer_name`      | string  | no       | Pre-fills the hosted page.                             |
| `customer_phone`     | string  | no       | E.164 if you have it.                                  |
| `description`        | string  | no       | Shown on the hosted page.                              |
| `expires_in_seconds` | integer | no       | Default 1800 (30 min). Range 60 – 86400.               |
| `metadata`           | object  | no       | Free-form key/value. Echoed back on the webhook.       |
| `external_invoice_id`| string  | no       | If this checkout corresponds to one of your invoices.  |
| `website_key`        | string  | no       | If you operate multiple websites under one merchant.   |

### Response

```json
{
  "session": {
    "session_id": "ces_abc123",
    "checkout_url": "https://pay.asterpos.com/checkout/ces_abc123",
    "asterpos_checkout_url": "https://pay.asterpos.com/checkout/ces_abc123",
    "status": "created",
    "amount_cents": 1500,
    "currency": "USD",
    "expires_at": "2026-06-06T20:00:00Z",
    "provider_mode": "asterpos_hosted_checkout"
  }
}
```

Redirect the customer's browser to `session.checkout_url` (or
`provider_checkout_url` if non-null — see below).

> **Provider-hosted page support**
> If your assigned payment profile routes through a processor that
> requires a processor-hosted page (e.g. iPOSpays HPP), the response
> includes a non-null `provider_checkout_url`. The AsterPOS hosted page
> auto-detects this and redirects on first load, so you can ALWAYS just
> redirect the customer to `checkout_url`.

### Example — cURL

```bash
curl -X POST https://api.asterpos.com/api/asterpos-ecommerce/checkout-sessions \
  -H "Authorization: Bearer ${ASTERPOS_PUBLISHABLE_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "external_order_id": "ORD-5001",
    "amount_cents": 12500,
    "currency": "USD",
    "success_url": "https://merchant.example.com/orders/5001/success",
    "cancel_url":  "https://merchant.example.com/orders/5001/cancel",
    "webhook_url": "https://merchant.example.com/webhooks/asterpos",
    "idempotency_key": "ord-5001-attempt-1",
    "customer_email": "buyer@example.com",
    "description": "Order #5001 — 3 items",
    "metadata": { "cart_id": "cart_8472" }
  }'
```

### Example — Node.js (fetch)

```js
async function createCheckoutSession(order) {
  const res = await fetch(
    "https://api.asterpos.com/api/asterpos-ecommerce/checkout-sessions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.ASTERPOS_PUBLISHABLE_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        external_order_id: order.id,
        amount_cents: order.total_cents,
        currency: order.currency,
        success_url: `https://shop.example.com/orders/${order.id}/success`,
        cancel_url:  `https://shop.example.com/orders/${order.id}/cancel`,
        webhook_url: "https://shop.example.com/webhooks/asterpos",
        idempotency_key: `ord-${order.id}-${order.attempt}`,
        customer_email: order.customer.email,
        metadata: { cart_id: order.cart_id },
      }),
    },
  );
  if (!res.ok) throw new Error(`AsterPOS ${res.status}: ${await res.text()}`);
  const { session } = await res.json();
  return session.checkout_url;
}
```

### Example — Python (httpx)

```python
import os, httpx

def create_checkout_session(order) -> str:
    payload = {
        "external_order_id": order.id,
        "amount_cents": order.total_cents,
        "currency": order.currency,
        "success_url": f"https://shop.example.com/orders/{order.id}/success",
        "cancel_url":  f"https://shop.example.com/orders/{order.id}/cancel",
        "webhook_url": "https://shop.example.com/webhooks/asterpos",
        "idempotency_key": f"ord-{order.id}-{order.attempt}",
        "customer_email": order.customer.email,
        "metadata": {"cart_id": order.cart_id},
    }
    r = httpx.post(
        "https://api.asterpos.com/api/asterpos-ecommerce/checkout-sessions",
        json=payload,
        headers={"Authorization": f"Bearer {os.environ['ASTERPOS_PUBLISHABLE_KEY']}"},
        timeout=10.0,
    )
    r.raise_for_status()
    return r.json()["session"]["checkout_url"]
```

### Read a session

`GET /api/asterpos-ecommerce/checkout-sessions/{session_id}` is **public**
(no auth) and returns only fields safe to expose to the customer's
browser — your `webhook_url` and raw `metadata` are NOT returned here.
Use webhooks for server-side state, not polling.

---

## Payment Links

A payment link is a long-lived (default 30 days) URL you embed in
emails, SMS messages, QR codes, or PDFs. Functionally it's a checkout
session with a different prefix (`pl_*`) and a friendlier URL
(`pay.asterpos.com/pay/{id}`).

### Create a payment link

`POST /api/asterpos-ecommerce/payment-links`

Same fields as `/checkout-sessions`, with these differences:

* `expires_in_seconds` range is **60 to 31,536,000** (1 year).
* `external_invoice_id` is the most common field merchants set
  (invoice payment) but it is still optional.

### Example — cURL

```bash
curl -X POST https://api.asterpos.com/api/asterpos-ecommerce/payment-links \
  -H "Authorization: Bearer ${ASTERPOS_PUBLISHABLE_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "external_order_id":    "INV-2026-0042",
    "external_invoice_id":  "INV-2026-0042",
    "amount_cents":         50000,
    "currency":             "USD",
    "success_url":          "https://merchant.example.com/invoices/42/paid",
    "cancel_url":           "https://merchant.example.com/invoices/42",
    "webhook_url":          "https://merchant.example.com/webhooks/asterpos",
    "idempotency_key":      "invoice-42-v1",
    "customer_email":       "buyer@example.com",
    "customer_phone":       "+15555550100",
    "description":          "Invoice #42 — Two-coat epoxy supplies",
    "expires_in_seconds":   1209600
  }'
```

Response:

```json
{
  "payment_link": {
    "payment_link_id": "pl_xyz789",
    "url": "https://pay.asterpos.com/pay/pl_xyz789",
    "amount_cents": 50000,
    "currency": "USD",
    "status": "created",
    "expires_at": "2026-06-20T20:00:00Z"
  }
}
```

The `url` field is what you embed in your email / SMS / QR / PDF.

### Example — Node.js (email template)

```js
const link = await createPaymentLink(invoice);
await sendEmail({
  to: invoice.customer_email,
  subject: `Invoice #${invoice.id}`,
  html: `<p>Your invoice for ${formatMoney(invoice.total)} is ready.</p>
         <p><a href="${link.url}">Pay invoice #${invoice.id}</a></p>`,
});
```

### Example — Python (PDF embed)

```python
link = create_payment_link(invoice)
pdf.draw_string("Pay online: " + link["url"])
pdf.draw_qr(link["url"])   # any QR library
```

---

## Refunds

Refund a previously-paid checkout session, in full or in part. Refunds
are routed through the same Payment Hub orchestrator that processed the
original sale — your website never talks to the underlying processor.

### Endpoint
```
POST /api/asterpos-ecommerce/refunds
Authorization: Bearer <publishable key>
Content-Type: application/json
```

### Request body

| Field             | Type    | Required | Notes |
|-------------------|---------|----------|-------|
| `transaction_id`  | string  | one of   | AsterPOS Ecommerce transaction UUID. **Preferred** — unambiguous. |
| `payment_id`      | string  | one of   | External order id you sent on the original session. Falls back to most-recent paid transaction for that order. |
| `amount_cents`    | integer | no       | Partial-refund amount in minor units. **Omit for full refund** of the remaining unrefunded balance. |
| `reason`          | string  | no       | Free-text reason (max 512 chars) — echoed in the webhook. |
| `idempotency_key` | string  | **yes**  | 8–128 chars. Duplicate keys return the original refund. |
| `metadata`        | object  | no       | Arbitrary JSON. Keys starting with `_` are stripped from the webhook payload. |

### Examples

**Full refund (curl):**
```bash
curl -X POST https://api.asterpos.com/api/asterpos-ecommerce/refunds \
  -H "Authorization: Bearer $ASTERPOS_PK" \
  -H "Content-Type: application/json" \
  -d '{
    "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
    "reason": "customer requested",
    "idempotency_key": "refund-2026-02-15-001"
  }'
```

**Partial refund (Node.js):**
```javascript
const resp = await fetch("https://api.asterpos.com/api/asterpos-ecommerce/refunds", {
    method: "POST",
    headers: {
        "Authorization": `Bearer ${process.env.ASTERPOS_PK}`,
        "Content-Type": "application/json",
    },
    body: JSON.stringify({
        payment_id: "ORDER-2026-1234",
        amount_cents: 1500,
        reason: "partial shipping refund",
        idempotency_key: `refund-${order.id}-001`,
    }),
});
const { refund } = await resp.json();
console.log(refund.refund_id, refund.status, refund.session_status);
```

**Partial refund (Python):**
```python
import os, requests
resp = requests.post(
    "https://api.asterpos.com/api/asterpos-ecommerce/refunds",
    headers={
        "Authorization": f"Bearer {os.environ['ASTERPOS_PK']}",
        "Content-Type": "application/json",
    },
    json={
        "transaction_id": txn_id,
        "amount_cents": 1500,
        "idempotency_key": f"refund-{order_id}-001",
    },
)
refund = resp.json()["refund"]
```

### Response (201 Created)

```json
{
  "refund": {
    "refund_id":          "ref_4f3a2b1c…",
    "transaction_id":     "550e8400-e29b-41d4-a716-446655440000",
    "session_id":         "ces_…",
    "external_order_id":  "ORDER-2026-1234",
    "external_invoice_id": null,
    "amount_cents":       1500,
    "currency":           "USD",
    "status":             "succeeded",
    "session_status":     "partially_refunded",
    "reason":             "partial shipping refund",
    "created_at":         "2026-02-15T20:11:34.123456+00:00",
    "metadata":           { "order_line": 3 }
  }
}
```

### Status transitions

| Original session | After refund                                            |
|------------------|---------------------------------------------------------|
| `paid`           | `partially_refunded` (partial) or `refunded` (full)     |
| `partially_refunded` | `partially_refunded` (still partial) or `refunded` (now full) |

`refunded` and `partially_refunded` are terminal states — your website
can stop polling the session after either.

### Error codes

| HTTP | `error`                  | When |
|------|--------------------------|------|
| 400  | `missing_target`         | Neither `transaction_id` nor `payment_id` supplied |
| 400  | `missing_idempotency_key`| No idempotency key |
| 400  | `invalid_amount`         | `amount_cents` is ≤ 0 |
| 404  | `transaction_not_found`  | No such transaction OR not owned by your merchant |
| 404  | `transaction_not_owned`  | Same as above — never differentiated to avoid info leak |
| 409  | `non_refundable_state`   | Session is not `paid` / `partially_refunded` |
| 409  | `over_refund`            | Refund amount exceeds remaining refundable balance |
| 422  | `payments_not_configured`| Merchant has no active Payment Hub profile (or original payment wasn't routed through Hub) |

### Refund webhooks

After every refund attempt we POST a signed event to your `webhook_url`.

**`refund.succeeded`** — refund accepted by the processor:
```json
{
  "id":         "evt_…",
  "type":       "refund.succeeded",
  "created":    1707000000,
  "merchant":   "aem_…",
  "data": {
    "refund_id":          "ref_…",
    "transaction_id":     "uuid",
    "session_id":         "ces_…",
    "external_order_id":  "ORDER-…",
    "external_invoice_id": null,
    "amount_cents":       1500,
    "currency":           "USD",
    "status":             "succeeded",
    "session_status":     "partially_refunded",
    "reason":             "partial shipping refund",
    "metadata":           { "order_line": 3 },
    "created_at":         "2026-02-15T20:11:34.123456+00:00"
  }
}
```

**`refund.failed`** — provider rejected the refund. Session status is
unchanged; you may safely retry with a new `idempotency_key` once you
understand the failure reason.
```json
{
  "type": "refund.failed",
  "data": {
    "refund_id":     "ref_…",
    "amount_cents":  1500,
    "status":        "failed",
    "session_status": "paid",
    "error_message": "…"
  }
}
```

The signature header (`X-AsterPOS-Ecommerce-Signature`) is computed
identically to payment webhooks — use the same verifier sample.

### GET a refund

```
GET /api/asterpos-ecommerce/refunds/{refund_id}
Authorization: Bearer <publishable key>
```

Returns the same shape as the create response. Merchant-scoped: you only
see your own refunds.

---

## Future Customer Wallet  **— Coming Soon**

> ⏳ **This section describes a future product. The endpoints below are
> RESERVED but NOT yet exposed to merchants.** Saved-card / one-click
> checkout / cross-merchant wallet features are part of the upcoming
> AsterPOS Link product. Do not build against these surfaces yet; the
> public contract may change.

### Vision

A customer who has paid on **one** AsterPOS-powered merchant website
will, in a future release, be able to:

- Sign in to any other AsterPOS-powered merchant website with the same
  AsterPOS identity (email and/or phone).
- See their saved payment methods (brand + last4 + expiration only —
  never a vault token, never a PAN).
- Complete checkout in **one click** — no re-entering card details.
- Manage their wallet from a customer portal (sign-in, verification,
  remove cards, etc.).

Existing today (Phase 2 foundation):

- **Customer identity model** — `asterpos_customers` is GLOBAL across
  merchants. A single AsterPOS customer can be used on Epoxy Warehouse,
  Coating Warehouse, Merchant A, Merchant B, etc.
- **Safe-reference payment-method model** — `asterpos_customer_payment_methods`
  stores `payment_hub_payment_method_id` + brand + last4 + expiration.
  The Payment Hub vault remains the sole owner of the actual token
  lifecycle.
- **Vault boundary** — AsterPOS Ecommerce NEVER stores PAN, CVV, or raw
  vault tokens. All tokenization happens inside Payment Hub.

NOT yet built (future phases):

- Remember-card checkbox on the hosted checkout
- One-click checkout via the wallet
- Customer login / portal UI
- Email / SMS OTP verification flow
- Cross-device wallet sync

### Reserved endpoints (internal only)

These exist today behind the admin API key. Merchant publishable keys
return 401 — by design, until the wallet UX ships.

| Method | Path | Status |
|---|---|---|
| `POST` | `/api/asterpos-ecommerce/admin/customers/resolve` | admin-only |
| `GET`  | `/api/asterpos-ecommerce/admin/customers/{customer_id}` | admin-only |
| `GET`  | `/api/asterpos-ecommerce/admin/customers/{customer_id}/payment-methods` | admin-only |

When AsterPOS Link launches, the public surface will mirror these with
merchant-scoped authorization and Link-flavoured UI components.

---

## Webhooks

After every terminal state change, we POST a signed event to your
`webhook_url`. The event types are frozen public API:

| `type`                       | Sent when                                       |
|------------------------------|-------------------------------------------------|
| `payment.succeeded`          | Session moved to `paid`                         |
| `payment.failed`             | Session moved to `failed`                       |
| `payment.canceled`           | Customer or API called cancel                   |
| `checkout.session.created`   | (optional) New session created                  |
| `checkout.session.expired`   | TTL elapsed without payment                     |

### Envelope shape (frozen)

```json
{
  "id":           "evt_abc123",
  "api_version":  "2026-06-01",
  "type":         "payment.succeeded",
  "created_at":   "2026-06-06T20:00:00Z",
  "merchant_key": "aem_yourmerchantkey",
  "data": {
    "session_id":          "ces_abc123",
    "external_order_id":   "ORD-5001",
    "external_invoice_id": null,
    "amount_cents":        12500,
    "currency":            "USD",
    "status":              "paid",
    "provider":            "ipospays",
    "provider_transaction_id": "...",
    "customer_email":      "buyer@example.com",
    "customer_name":       "Jane Doe",
    "metadata":            { "cart_id": "cart_8472" }
  }
}
```

### Headers we send

```
X-AsterPOS-Ecommerce-Signature:  t=1735689600,v1=<hex_signature>
X-AsterPOS-Ecommerce-Event-Id:   evt_abc123
X-AsterPOS-Ecommerce-Timestamp:  1735689600
X-AsterPOS-Ecommerce-Api-Version: 2026-06-01
Content-Type:                    application/json
```

### Verifying the signature

Drop one of the reference verifiers below into your codebase. Both are
stdlib-only (no dependencies) and identical-in-shape. Production
implementations live at:

* Python — [`backend/docs/samples/asterpos_webhook_verifier.py`](../samples/asterpos_webhook_verifier.py)
* Node.js — [`backend/docs/samples/asterpos-webhook-verifier.js`](../samples/asterpos-webhook-verifier.js)

The verifier:

1. Parses `t=...,v1=...` from the signature header.
2. Computes `HMAC_SHA256(secret, "${t}.${raw_body}")` and constant-time
   compares against each `v1=` value.
3. Rejects timestamps more than **5 minutes** old (replay protection)
   and more than **1 minute** in the future (clock skew).

### Express (Node.js) — minimal handler

```js
import express from "express";
import { verifyWebhook, SignatureVerificationError } from "./asterpos-webhook-verifier.js";

const app = express();

// IMPORTANT: get the *raw* body. JSON parsing re-serializes and the
// signature would no longer match. Express only.
app.post(
  "/webhooks/asterpos",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    try {
      verifyWebhook({
        secret:  process.env.ASTERPOS_WEBHOOK_SECRET,
        payload: req.body, // Buffer of the raw bytes we sent
        header:  req.headers["x-asterpos-ecommerce-signature"],
      });
    } catch (e) {
      if (e instanceof SignatureVerificationError) {
        return res.status(401).send(e.message);
      }
      return res.status(400).send("Bad request");
    }
    const event = JSON.parse(req.body.toString("utf8"));
    await handleEvent(event); // see dedupe + mark-paid examples below
    res.sendStatus(200);
  },
);
```

### FastAPI (Python) — minimal handler

```python
from fastapi import FastAPI, HTTPException, Request
from asterpos_webhook_verifier import verify_webhook, SignatureVerificationError
import os, json

app = FastAPI()

@app.post("/webhooks/asterpos")
async def asterpos_webhook(request: Request):
    body = await request.body()
    try:
        verify_webhook(
            secret=os.environ["ASTERPOS_WEBHOOK_SECRET"],
            payload=body,
            header=request.headers.get("X-AsterPOS-Ecommerce-Signature", ""),
        )
    except SignatureVerificationError as e:
        raise HTTPException(status_code=401, detail=str(e))
    event = json.loads(body)
    await handle_event(event)
    return {"received": True}
```

### Event dedupe (recommended)

Webhook delivery is **at-least-once**. We retry on any non-2xx response
(see [retry schedule](#retry-schedule) below). Your handler must be
idempotent — store `event.id` in a dedupe table and skip events you've
already processed:

```python
async def handle_event(event):
    if await event_already_processed(event["id"]):
        return  # already handled — return 200 quickly
    async with db.transaction():
        await mark_event_processed(event["id"])
        await dispatch(event)
```

### Marking order/invoice paid

```js
async function handleEvent(event) {
  if (event.type === "payment.succeeded") {
    const orderId = event.data.external_order_id;
    await db.orders.update(orderId, {
      status: "paid",
      paid_at: event.created_at,
      asterpos_session_id: event.data.session_id,
      asterpos_transaction_id: event.data.provider_transaction_id,
    });
  } else if (event.type === "payment.failed") {
    await db.orders.update(event.data.external_order_id, {
      status: "payment_failed",
      last_error: event.data.error_message,
    });
  } else if (event.type === "payment.canceled") {
    await db.orders.update(event.data.external_order_id, { status: "canceled" });
  }
}
```

For payment links the same handler works — branch on
`event.data.external_invoice_id` if it's present:

```js
if (event.data.external_invoice_id) {
  await db.invoices.markPaid(event.data.external_invoice_id, event);
} else {
  await db.orders.markPaid(event.data.external_order_id, event);
}
```

### Retry schedule

If your endpoint returns anything other than a `2xx` status (or we get a
network error / timeout > 10s), we retry on this schedule:

| Attempt | Wait after previous |
|---------|---------------------|
| 1       | immediate           |
| 2       | 60 seconds          |
| 3       | 5 minutes           |
| 4       | 30 minutes          |
| 5       | 2 hours             |
| 6       | 12 hours            |

After 6 attempts (~14 hours total) the event is marked failed
permanently. Operators can re-deliver from the admin UI (PR 8).

### Webhook security checklist

- [ ] Verify the signature on **every** request. Reject 401 if it fails.
- [ ] Use the **raw** request body for verification, not a re-serialized JSON.
- [ ] Compare timestamps against your clock (NTP-synced).
- [ ] Dedupe on `event.id` to handle retries safely.
- [ ] Return `2xx` quickly (< 5s). Defer heavy work to a queue.
- [ ] Never log the `webhook_secret` or the raw signature.

---

## Idempotency

Send the same `idempotency_key` to retry a `POST /checkout-sessions` or
`POST /payment-links` call. We return the **existing** session — we will
not create a duplicate.

The uniqueness scope is `(merchant, external_order_id, idempotency_key)`.
Two different idempotency keys with the same `external_order_id` are
allowed — useful when a customer abandons checkout and you want to
start a fresh attempt:

```
ord-5001-attempt-1   → first checkout
ord-5001-attempt-2   → customer came back, second attempt
```

Recommended key shapes:

| Use case                | Key                                  |
|-------------------------|--------------------------------------|
| First checkout attempt  | `ord-{order_id}-v1`                  |
| Customer retry          | `ord-{order_id}-v{n}`                |
| Invoice link            | `inv-{invoice_id}-v1`                |
| Quote deposit           | `quote-{quote_id}-deposit-v1`        |
| Subscription renewal    | `sub-{sub_id}-period-{period_id}`    |

---

## Error handling

All errors return a JSON body:

```json
{ "detail": "human-readable reason" }
```

Common codes:

| Status | Meaning                                                       |
|--------|---------------------------------------------------------------|
| `400`  | Malformed JSON, missing required field, invalid amount/currency |
| `401`  | Missing or invalid `Authorization: Bearer pk_*`                |
| `403`  | Publishable key resolves to a non-active merchant              |
| `404`  | Session id not found (on `GET`)                                |
| `409`  | Session is already in a terminal state (e.g. cancel after paid)|
| `503`  | AsterPOS Ecommerce is temporarily disabled on this deployment  |

Retryable on the client side:

* `5xx` — exponential backoff, max 5 tries.
* `429` — respect `Retry-After` if present.
* Network errors / timeouts — exponential backoff.

NOT retryable without a fix:

* `400` / `401` / `403` / `404` — your request is wrong.

---

## Sandbox testing

The sandbox environment uses the **same API surface** as production with
a `mock` provider that returns deterministic outcomes based on a hidden
`test_scenario` field on the hosted page.

The AsterPOS-hosted checkout page (`sandbox.pay.asterpos.com`) shows
six scenario chips beneath the card form:

* **Approve** — `payment.succeeded` webhook
* **Decline** — `payment.failed` with code `05`
* **Insufficient funds** — `payment.failed` with code `51`
* **Expired card** — `payment.failed` with code `54`
* **Provider error** — `payment.failed` with code `96`, no `provider_transaction_id`
* **Timeout** — `payment.failed` with code `TIMEOUT`, no `provider_transaction_id`

### Sandbox integration test recipe

```python
# Run from your machine; pip install httpx
import httpx, os, time, uuid

API  = "https://sandbox-api.asterpos.com"
PK   = os.environ["ASTERPOS_PUBLISHABLE_KEY"]

def create():
    r = httpx.post(
        f"{API}/api/asterpos-ecommerce/checkout-sessions",
        headers={"Authorization": f"Bearer {PK}"},
        json={
            "external_order_id":  f"sandbox-{uuid.uuid4().hex[:8]}",
            "amount_cents":       1234,
            "currency":           "USD",
            "success_url":        "https://example.test/success",
            "cancel_url":         "https://example.test/cancel",
            "webhook_url":        "https://example.test/webhook",
            "idempotency_key":    f"idem-{uuid.uuid4().hex}",
        },
        timeout=10.0,
    )
    r.raise_for_status()
    return r.json()["session"]

s = create()
print("Open this URL and click Approve:", s["checkout_url"])
```

To validate webhooks without exposing your laptop, use a tunnel such as
[ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/):

```bash
ngrok http 4000
# Use the https://abc123.ngrok.io URL as webhook_url
```

### Sandbox completeness checklist

Before requesting production activation, demonstrate:

- [ ] `POST /checkout-sessions` returns a valid `checkout_url`.
- [ ] `POST /payment-links` returns a valid `pay.asterpos.com/pay/{id}` URL.
- [ ] Approve scenario delivers `payment.succeeded` to your endpoint.
- [ ] Decline scenario delivers `payment.failed`.
- [ ] Your verifier returns `401` on a tampered payload.
- [ ] Your handler is idempotent (replaying the same event does not double-mark paid).
- [ ] Cancel from the hosted page delivers `payment.canceled`.
- [ ] Expired sessions deliver `checkout.session.expired`.

---

## Production activation

1. Email your AsterPOS contact with:
   * The sandbox `external_order_id` of a successful test
   * Production `webhook_url`
   * Production `success_url` / `cancel_url` base
2. AsterPOS issues your **production** `publishable_key` and `webhook_secret`.
3. Swap the env vars in your deployment. The base URL changes from
   `sandbox-api.asterpos.com` to `api.asterpos.com`. **No code changes.**
4. Test one real $0.01 transaction end-to-end.
5. Go live.

Production secrets are rotated by contacting AsterPOS. Sandbox keys may
be rotated self-service via the admin API once PR 8 ships.

---

## Reference

* [Production OpenAPI spec](https://api.asterpos.com/api/docs) (Swagger UI)
* [Frozen webhook contract](../samples/) (Python + Node reference verifiers)
* [Architecture overview](./ASTERPOS_ECOMMERCE_DESIGN.md)
