# Integrate a product with PayStream

As of 2026-11-09.

The copy to give an agent is <https://docs.paystreams.co.za/integrate.md>.

This is the brief for an agent adding PayStream billing to another application. TutorPro is the worked example. The same calls apply to any product that sells a subscription or a one-off platform charge in South African rand.

PayStream is the billing service. The product keeps its own users, login, and the feature being sold. PayStream keeps the price that was agreed, the hosted checkout, the card, the invoices, the tax invoices, the renewals, and the two dunning emails.

Read this file before writing code. Implement only the routes and fields it lists. If a step is missing here, it is missing from the API.

## Rules

Follow these even when another payment API you know does something else.

1. Call PayStream from the product's server. The API key never goes to a browser, a mobile app, or a logged-in user's request.
2. Send the buyer to the `checkoutUrl` PayStream returns. The product does not collect a card number, build a card form, or call Paystack.
3. A browser landing on the product's success or cancel URL changes nothing. A trial grants access from the `trialing` status on the create response. A paid start grants access only from `subscription.activated`, or from `GET /subscriptions/{id}` showing `active`. Remove access from the later webhooks, or from a `GET` that shows `past_due`, `unpaid`, or `cancelled`.
4. Access follows the subscription status table below. `trialing` and `active` may use the paid feature. Every other status may not.
5. Store the ids PayStream returns. There is no list route for subscriptions or invoices.
6. Prices are VAT-inclusive ZAR cents. `19900` is R199.00. Do not add 15% on top, and do not send a currency field.
7. Retry a create or a refund with the same `Idempotency-Key` and the same body. A new purchase needs a new key.
8. Verify every webhook against the raw body before parsing it. Acknowledge with a 2xx within 10 seconds. Handle the event idempotently.
9. Leave `/admin`, `/console`, and `/gateway` alone. An operator provisions the product and sets its webhook URL. The product uses the tenant API key only.

## What the operator does first

The product agent does not do this. A person does it in the PayStream console at `https://console.paystreams.co.za`.

1. Create the product. Its id is a slug, 2 to 40 characters, lowercase letters, digits, and hyphens, such as `tutorpro`. That id is the tenant. It is not sent on later calls. The API key selects it.
2. Set the display name, logo, and primary colour. Checkout and email use that name.
3. Set the webhook URL to the product's public `https` endpoint. `http` is accepted only for `localhost`.
4. Create the plans, or leave that to the product's `PUT /plans/{code}` calls.
5. Copy the API key (`psk_…`) and the webhook signing secret (`whsec_…`) into the product's server configuration. Each is shown once at issue. Replacing either leaves the old value working for 7 days, or until the operator revokes it. During that overlap PayStream signs each webhook with every active secret.

Give the product three settings and nothing else:

| Setting | Production value |
|---|---|
| Base URL | `https://api.paystreams.co.za` |
| API key | `psk_…` from the console |
| Webhook secret | `whsec_…` from the console |

Local PayStream, with the Fake gateway, uses base URL `http://localhost:8080`. The checkout page then has a **Pay (test)** button. Production has no Fake gateway and no test-pay route.

## Words

| Word | Meaning |
|---|---|
| Product | One tenant. TutorPro is one product. A person who also uses another CodeStream app is a different subscriber there. |
| Subscriber | The buyer, keyed by the product's own user id (`externalId`). |
| Plan | A code, a name, a VAT-inclusive price, an interval of `month` or `year`, and a trial of 0 to 365 days. |
| Subscription | One membership on the amount and interval copied from the plan at the moment it started. Editing the plan later does not change it. |
| Invoice | A subscription charge, or a one-off platform charge. Status is `open`, `paid`, `refund_pending`, or `refunded`. |
| Checkout | A PayStream-hosted page. The link lives 24 hours. |

PayStream does not offer plan changes, proration, seats, usage, coupons, pauses, partial refunds, a customer portal, or a second currency. A subscription keeps the price it started with. To move a buyer onto another plan, cancel this subscription and start a new one after it has reached `cancelled`. Starting the new one while the old one is still `active` or `trialing` bills both.

## Who may use the paid feature

Store `status` from the subscription object and gate the feature with this table.

| Status | The buyer may use the paid feature | How it happens |
|---|---|---|
| `incomplete` | No | Started with no trial. The first invoice is unpaid. |
| `trialing` | Yes | Started with a trial. The trial is already running when `POST /subscriptions` returns, including before the buyer stores a card. |
| `active` | Yes | A period has been paid. Stays `active` while `cancelAtPeriodEnd` is true. |
| `past_due` | No | The renewal charge failed, or a renewal had no card on file. |
| `unpaid` | No | The card retries are exhausted, or an invoice with no card is still open 7 days after its period start. |
| `cancelled` | No | The buyer cancelled, the period ended after a scheduled cancellation, or PayStream accepted a refund of a subscription invoice. |

`cancelAtPeriodEnd: true` with status `active` or `trialing` still has access. Remove access when a later event shows `cancelled`.

Paying a `past_due` or `unpaid` subscription makes it `active` again. The paid period is the invoice's own period. Days spent unpaid sit inside that period. Do not extend the period by the unpaid days.

A trial's R1.00 card-setup charge (100 cents, unless an operator has changed it) is refunded automatically. It is not revenue, it creates no invoice, and it sends no webhook. The buyer may see that rand, and its refund, on their statement. Say so on the trial screen.

## Calling the API

Send JSON with `Content-Type: application/json`. PayStream uses camelCase names. Enums are lower snake case strings (`past_due`, `one_off`, `instant_eft`, `refund_pending`).

```http
Authorization: Bearer psk_…
Content-Type: application/json
```

Success bodies are `application/json`. Validation and conflict bodies are `application/problem+json` with `title`, `status`, and `detail`. A rejected refund is the exception: HTTP 422 with `{ "reason": "…" }`.

There is no version prefix and no tenant header. Routes are at the base URL root.

| Result | When |
|---|---|
| 400 | The body or an `Idempotency-Key` failed validation. |
| 401 | The API key is missing, unknown, expired, or revoked. |
| 404 | That subscriber, plan, subscription, or invoice does not exist on this product. Another product's id is also a 404. |
| 409 | A concurrent duplicate of an idempotent create, a retired plan, or a refund PayStream will not attempt. |
| 422 | This `Idempotency-Key` was already used for a different body, or the gateway rejected the refund. |
| 503 | The refund call did not get an answer. The body detail is `gateway_unavailable`. This response is not saved. Retry the same key and the same invoice. |

`Idempotency-Key` is required on `POST /subscriptions`, `POST /invoices`, and `POST /invoices/{id}/refund`. The same key and the same body return the original status and body and do not create a second object. Subscribers and plans are idempotent by their own id and do not use the header.

Generate the key in the product, store it with the command, and send that same key if the request times out. A second click that is a new purchase gets a new key.

## Subscribers

The path id is the product's user id. Use a stable id. Email is a field, because people change email addresses. URL-encode the path segment.

`PUT /subscribers/{externalId}` creates or replaces the name and email.

```json
{ "name": "Thandi Mokoena", "email": "thandi@example.com" }
```

```json
{ "subscriberId": "user_8f3a", "name": "Thandi Mokoena", "email": "thandi@example.com" }
```

Name is required. Email must be a real address with a dot in the host. Call this again when the name or email changes, so the dunning mail goes to the current address.

`GET /subscribers/{externalId}` returns the same object, or 404.

## Plans

`PUT /plans/{code}` creates or updates a plan. It does not retire one.

```json
{ "name": "Tutor monthly", "amountCents": 19900, "interval": "month", "trialDays": 0 }
```

```json
{ "code": "tutor-monthly", "name": "Tutor monthly", "amountCents": 19900, "interval": "month", "trialDays": 0 }
```

The code is 2 to 40 characters of lowercase letters, digits, and hyphens. `amountCents` is at least 100. `interval` is `month` or `year`. `trialDays` is 0 to 365.

`GET /plans/{code}` returns that object, or 404. The response has no retired flag. Starting a subscription on a plan an operator retired returns 409 with title `Plan retired`. Show that as "this plan is no longer on sale". Retiring and reactivating happen in the console.

A subscription copies `amountCents` and `interval` at start. A later `PUT` changes new subscriptions only. Subscriptions already on a retired plan keep renewing.

## Start a subscription

Create the subscriber and the plan first.

`POST /subscriptions` with `Idempotency-Key`.

```json
{
  "subscriberId": "user_8f3a",
  "planCode": "tutor-monthly",
  "successUrl": "https://tutorpro.example/billing/return?subscriptionId=WILL_REPLACE&outcome=success",
  "cancelUrl": "https://tutorpro.example/billing/return?subscriptionId=WILL_REPLACE&outcome=cancel"
}
```

Both return URLs must be absolute `https`, or `http` only when the host is `localhost`. PayStream redirects the browser to the URL it stored and appends nothing, so put the product's own correlation in the URL. You do not know the subscription id until the response, so either use a product-generated attempt id in the URL, or ignore the query string and read the stored subscription id on the server when the buyer comes back.

```json
{
  "subscriptionId": "2c0e1b7a-6b3e-4e1a-9f0a-0a6b1c2d3e4f",
  "status": "incomplete",
  "checkoutUrl": "https://api.paystreams.co.za/checkout/2f5a0c1d4e6b7890a1b2c3d4e5f67890"
}
```

HTTP 201. Store `subscriptionId` on the user. Send the buyer to `checkoutUrl` immediately. The path id has no hyphens.

Without a trial the status is `incomplete` and the first invoice has no period yet. The period starts when that invoice is paid, from the payment time, for one interval. An anchor on 31 January ends the first month on 28 February, or 29 February in a leap year, and the next month on 31 March.

With a trial the status is `trialing`, `trialEndsAt` is set, and the checkout is the card-setup charge. The first real period is period 0, anchored at the trial end. `subscription.activated` arrives only when that first period is paid.

`GET /subscriptions/{id}` is the current subscription:

```json
{
  "id": "2c0e1b7a-6b3e-4e1a-9f0a-0a6b1c2d3e4f",
  "subscriberId": "user_8f3a",
  "planCode": "tutor-monthly",
  "status": "active",
  "amountCents": 19900,
  "interval": "month",
  "trialEndsAt": null,
  "currentPeriodStart": "2026-11-09T08:00:00+00:00",
  "currentPeriodEnd": "2026-12-09T08:00:00+00:00",
  "cancelAtPeriodEnd": false,
  "card": { "last4": "4242", "expiryMonth": 12, "expiryYear": 2030 },
  "openInvoiceId": null
}
```

`card` is null until a card payment has stored one. Instant EFT stores no card. The card object is safe to show. It is never a full card number.

While a user has a subscription in `incomplete`, `trialing`, `active`, `past_due`, or `unpaid`, do not start another one for a single membership. PayStream will accept a second `POST`. The product has to refuse it.

If the buyer left and the 24-hour link has expired, the subscription is still `incomplete` and PayStream has no route that mints a fresh link. When they ask to try again, `GET` the subscription first. If it is already `active` or `trialing`, send them into the product. If it is still `incomplete`, cancel it, then `POST /subscriptions` with a new idempotency key and send them to the new `checkoutUrl`.

## One-off charge

Use this for a fee that does not renew: a placement test, a registration fee, a single purchase. It never becomes a subscription.

`POST /invoices` with `Idempotency-Key`.

```json
{
  "subscriberId": "user_8f3a",
  "description": "Placement test",
  "amountCents": 50000,
  "successUrl": "https://tutorpro.example/billing/return?invoiceId=WILL_REPLACE&outcome=success",
  "cancelUrl": "https://tutorpro.example/billing/return?invoiceId=WILL_REPLACE&outcome=cancel"
}
```

```json
{
  "invoiceId": "9a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "checkoutUrl": "https://api.paystreams.co.za/checkout/0a1b2c3d4e5f6789abcd0123456789ef"
}
```

HTTP 201. Store the invoice id against the thing it buys. Send the buyer to `checkoutUrl`. Grant the purchase when `invoice.paid` arrives for that id, or when `GET /invoices/{id}` shows `paid`. There is no `subscription.activated` for this charge.

`GET /invoices/{id}`:

```json
{
  "id": "9a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "kind": "one_off",
  "status": "paid",
  "subscriberId": "user_8f3a",
  "subscriptionId": null,
  "description": "Placement test",
  "amountCents": 50000,
  "periodStart": null,
  "periodEnd": null,
  "paidAt": "2026-11-09T08:04:00+00:00",
  "paymentMethod": "card",
  "taxInvoice": { "id": "…", "number": "PS-000184", "vatCents": 6522 },
  "creditNote": null
}
```

`kind` is `subscription` or `one_off`. `paymentMethod` is `card`, `instant_eft`, or null while the invoice is open. The VAT in an inclusive amount is `amountCents * 15 / 115`, rounded half up. PayStream writes that into the tax invoice. The product does not issue a second tax invoice for the same charge. There is no route to download the tax document. Show `taxInvoice.number` and `taxInvoice.vatCents` from this object.

A one-off invoice cannot be refunded through the API.

The first subscription invoice also has null `periodStart` and `periodEnd`. The period is on the subscription after it becomes `active`. Renewal invoices carry their period dates.

## Checkout

`checkoutUrl` is a PayStream page branded with the product's name, logo, and colour. The buyer pays by card or, when the gateway offers it, instant EFT. A card-setup checkout takes a card only.

The link expires 24 hours after it was created. The page then says the link has expired. A payment the gateway has already taken is still recorded if the notification arrives after that.

The browser returns to the product only as a redirect. Show "Confirming your payment" and wait for the webhook or for `GET`. On a trial, `GET` until `card` is set if the screen needs to say the card is on file. That confirmation can land after the redirect, and no webhook is sent for the card setup itself. Keep granting trial access from status `trialing` even while `card` is still null, unless the product has an explicit rule that a trial requires a card.

A card renewal is charged off session. The buyer is not present. If the bank or the gateway will not take it, the subscription becomes `past_due` and PayStream emails a new checkout link. Instant EFT stores no card, so every renewal goes straight to `past_due` until the buyer pays the link in that email.

## Cancel

`POST /subscriptions/{id}/cancel` has an empty body and no idempotency key. Calling it again is safe.

| Status when you cancel | What you get back |
|---|---|
| `active` or `trialing` | The same status, with `cancelAtPeriodEnd: true`. PayStream cancels at the period end, or the trial end, and does not charge that renewal. Access continues until the `subscription.cancelled` webhook. |
| `incomplete`, `past_due`, or `unpaid` | Status `cancelled`, now. Open checkout links for it stop working. |
| `cancelled` | The same object, unchanged. |

Cancelling does not refund a period already paid. Refunds are a separate call.

## Refund a subscription invoice

`POST /invoices/{id}/refund` with `Idempotency-Key` and an empty body. Take the invoice id from `invoice.paid` or from `openInvoiceId`. There is no list route.

PayStream refunds only a `paid` subscription invoice that has a gateway reference, and only the full amount.

| Response | What it means |
|---|---|
| 202 and the invoice with status `refund_pending` | The gateway accepted. The subscription is cancelled immediately, with `subscription.cancelled`. Remove access now. The invoice later becomes `refunded` and gains a `creditNote`, or returns to `paid` if the gateway then fails the refund. The subscription stays `cancelled` in both cases. Do not restore access. |
| 422 and `{ "reason": "…" }` | The gateway refused. The invoice is `paid` again. The subscription is unchanged. |
| 409 | This invoice cannot be refunded: it is one-off, or it is not a paid invoice with a gateway reference. |
| 503 and detail `gateway_unavailable` | No answer. Retry later with the same idempotency key. |

There is no `invoice.refunded` webhook. `subscription.cancelled` is the signal to remove access. `GET /invoices/{id}` is how you see `refunded` and the credit note number.

## Webhooks

PayStream `POST`s JSON to the product's webhook URL. The request times out at 10 seconds. Respond with a 2xx as soon as the signature checks and you have stored the event. Do the work after that, or inside that window if it is only a local write.

Any 2xx is success. Anything else is retried at 1 minute, 5 minutes, 30 minutes, 2 hours, and then every 6 hours. Delivery stops once the next attempt would fall more than 24 hours after the first. A product with no webhook URL gets no deliveries.

Headers:

```http
PayStream-Event-Id: 6f1e2d3c-4b5a-6978-8012-abcdef012345
PayStream-Signature: t=1762680000,v1=hex…,v1=hex…
Content-Type: application/json
```

`PayStream-Event-Id` equals the body's `id`. While two signing secrets are active, the header carries one `v1` per secret, newest first. Accept the request when any `v1` matches any secret the product still holds. A header with more than one `v1` is normal.

Each `v1` is lowercase hex HMAC-SHA256 of the UTF-8 string `{t}.{rawBody}`, keyed with the webhook secret. `rawBody` is the exact request bytes, not a re-serialised object. Reject a missing header, a `t` more than five minutes from the product's clock, or a signature that does not match. Compare signatures in constant time.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function signedBy(secret: string, header: string, rawBody: string, nowMs = Date.now()): boolean {
  let timestamp: string | undefined;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=");
    if (key === "t") timestamp = value;
    else if (key === "v1" && value) signatures.push(value);
  }
  if (!timestamp || signatures.length === 0) return false;
  const sentMs = Number(timestamp) * 1000;
  if (!Number.isFinite(sentMs) || Math.abs(nowMs - sentMs) > 5 * 60 * 1000) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const expectedBytes = Buffer.from(expected);
  return signatures.some((sig) => {
    const got = Buffer.from(sig);
    return got.length === expectedBytes.length && timingSafeEqual(got, expectedBytes);
  });
}
```

Store `id` and ignore a delivery you have already applied. Return 200 for `test.ping` and for a type you do not know yet, so a console test and a future event do not retry for a day. `test.ping` looks like this, and its `data` is the product id only:

```json
{ "id": "…", "type": "test.ping", "created": "2026-11-09T08:00:00+00:00", "data": { "productId": "tutorpro" } }
```

The operator sends that from the console. Wire the endpoint and ask them to send it before you subscribe a real buyer.

### Events

`invoice.paid` and `subscription.activated` are delivered on separate workers. Either can arrive first. Handle each on its own.

`subscription.activated` is sent when a payment moves the subscription to `active` from any other status. A renewal that was already `active` sends `invoice.paid` and does not send `subscription.activated`. Leave access in place.

| Type | `data` | What the product does |
|---|---|---|
| `invoice.paid` | `{ "invoice": Invoice }` | Record the payment. For `kind: "one_off"`, grant what that invoice bought. For `kind: "subscription"`, record the invoice. Access still comes from the subscription status. |
| `subscription.activated` | `{ "subscription": Subscription }` | Store the status, period, and card. Grant access. |
| `subscription.past_due` | `{ "subscription": Subscription }` | Store the status. `openInvoiceId` is set. Remove access. |
| `subscription.unpaid` | `{ "subscription": Subscription }` | Store the status. Remove access. |
| `subscription.cancelled` | `{ "subscription": Subscription }` | Store the status. Remove access. `cancelAtPeriodEnd` is already over. |
| `test.ping` | `{ "productId": "tutorpro" }` | Return 200. |

The subscription object is the same shape as `GET /subscriptions/{id}`. The invoice object is the same shape as `GET /invoices/{id}`. The cancelled event does not include a reason. Remove access on every cancellation.

```json
{
  "id": "6f1e2d3c-4b5a-6978-8012-abcdef012345",
  "type": "subscription.activated",
  "created": "2026-11-09T08:00:12+00:00",
  "data": {
    "subscription": {
      "id": "2c0e1b7a-6b3e-4e1a-9f0a-0a6b1c2d3e4f",
      "subscriberId": "user_8f3a",
      "planCode": "tutor-monthly",
      "status": "active",
      "amountCents": 19900,
      "interval": "month",
      "trialEndsAt": null,
      "currentPeriodStart": "2026-11-09T08:00:00+00:00",
      "currentPeriodEnd": "2026-12-09T08:00:00+00:00",
      "cancelAtPeriodEnd": false,
      "card": { "last4": "4242", "expiryMonth": 12, "expiryYear": 2030 },
      "openInvoiceId": null
    }
  }
}
```

Nothing in this API emits `charge.*`, `withdrawal.*`, `customer.*`, or `invoice.refunded`.

## Mail the buyer already gets

PayStream sends plain-text mail from `billing@paystreams.co.za`, with the product's display name as the sender name. It sends mail in exactly two cases, both for an unpaid renewal, and both include a fresh 24-hour checkout link:

| When | Subject shape |
|---|---|
| The renewal first fails, and the subscription becomes `past_due` | `{Product}: your {description} payment did not go through` |
| The subscription becomes `unpaid` | `{Product}: your {description} subscription is unpaid` |

The first checkout, a receipt, a refund, a cancellation, the trial card setup, and a one-off invoice do not send mail. Do not send a second mail that carries a different pay link. The product can say the same thing in its own interface. It cannot mint the replacement link. For a `past_due` or `unpaid` buyer, point them at that email.

Those links return to the PayStream checkout page, which shows whether the payment is complete. The product still hears the result as `invoice.paid` and `subscription.activated`.

## Reconcile

For a subscription the product created and has not yet seen a terminal webhook for, `GET /subscriptions/{id}` is the authority. Do that when the buyer returns from checkout, and on a schedule for local rows still marked `incomplete` or waiting on a card, because webhook delivery gives up after 24 hours.

`GET /invoices/{id}` does the same for a one-off purchase.

## TutorPro

TutorPro sells access to tutoring. PayStream sells none of the tutoring. One PayStream product, `tutorpro`, bills every TutorPro user.

Store on the TutorPro user, server side:

| Column | Source |
|---|---|
| `paystream_subscription_id` | `POST /subscriptions` |
| `billing_status` | subscription `status` |
| `plan_code` | subscription `planCode` |
| `current_period_end` | subscription `currentPeriodEnd` |
| `cancel_at_period_end` | subscription `cancelAtPeriodEnd` |
| `card_last4` | subscription `card.last4` |

Gate booking and the rest of the paid product on `billing_status` of `trialing` or `active`.

Suggested plans, created once with `PUT` or in the console:

| Code | Price | Interval | Trial |
|---|---|---|---|
| `tutor-monthly` | R199.00 (`19900`) | `month` | 0 |
| `tutor-yearly` | R1990.00 (`199000`) | `year` | 0 |
| `tutor-trial` | R199.00 (`19900`) | `month` | 14 |

A placement test is `POST /invoices` with its own idempotency key, not a plan.

Sequence for "subscribe":

1. `PUT /subscribers/{tutorProUserId}` with the current name and email.
2. If that user already has a subscription that is not `cancelled`, stop and show the current one.
3. `POST /subscriptions` with a new idempotency key stored before the call. For the trial plan the response status is `trialing`. Otherwise it is `incomplete`.
4. Save the subscription id and send the browser to `checkoutUrl`.
5. If the response status is `trialing`, grant access now. If it is `incomplete`, leave the paid feature locked. On the return page, show that confirmation is in progress and `GET` the subscription. Grant a paid start only when that `GET` shows `active`, or when `subscription.activated` arrives.
6. When `subscription.activated` arrives, set `billing_status` to `active` and store the period end. A renewal of a subscription that is already `active` does not send this event.
7. When `subscription.past_due`, `subscription.unpaid`, or `subscription.cancelled` arrives, set that status and lock the paid feature.
8. A cancel button calls `POST /subscriptions/{id}/cancel`. If the response has `cancelAtPeriodEnd: true`, tell the buyer they keep access until `currentPeriodEnd`.

Sequence for "try again" after an abandoned first checkout: `GET` first, cancel only if the status is still `incomplete`, then start again with a new idempotency key.

Sequence for "refund this period": `POST /invoices/{id}/refund` using the invoice id from `invoice.paid`. On 202, lock the paid feature. The membership does not come back if the refund later fails at the bank.

Webhook route, for example `POST /api/paystream/webhook`. Verify the signature, store the event id, return 200, apply the table above. Unknown types return 200.

Ask the operator to send `test.ping` from the console to that URL before any live subscription.

## Done when

- The API key and webhook secret are only on the server.
- A trial grants access from the `trialing` create response. A paid start grants access only after `subscription.activated` or a `GET` that shows `active`.
- Returning to the success URL with no webhook and no matching `GET` leaves access unchanged.
- `past_due`, `unpaid`, and `cancelled` lock the paid feature, including when `invoice.paid` for a renewal arrives on its own.
- Cancelling an active subscription leaves access until `subscription.cancelled`.
- A one-off invoice grants only the purchase tied to that invoice id.
- A replayed webhook and a retried create do not create a second subscription or grant the purchase twice.
- `test.ping` returns 200.
- The product does not call Paystack, `/admin`, `/console`, `/gateway`, or any route this document does not list.
