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.
- 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.
- Send the buyer to the
checkoutUrlPayStream returns. The product does not collect a card number, build a card form, or call Paystack. - A browser landing on the product's success or cancel URL changes nothing. A trial grants access from the
trialingstatus on the create response. A paid start grants access only fromsubscription.activated, or fromGET /subscriptions/{id}showingactive. Remove access from the later webhooks, or from aGETthat showspast_due,unpaid, orcancelled. - Access follows the subscription status table below.
trialingandactivemay use the paid feature. Every other status may not. - Store the ids PayStream returns. There is no list route for subscriptions or invoices.
- Prices are VAT-inclusive ZAR cents.
19900is R199.00. Do not add 15% on top, and do not send a currency field. - Retry a create or a refund with the same
Idempotency-Keyand the same body. A new purchase needs a new key. - Verify every webhook against the raw body before parsing it. Acknowledge with a 2xx within 10 seconds. Handle the event idempotently.
- Leave
/admin,/console, and/gatewayalone. 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.
- 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. - Set the display name, logo, and primary colour. Checkout and email use that name.
- Set the webhook URL to the product's public
httpsendpoint.httpis accepted only forlocalhost. - Create the plans, or leave that to the product's
PUT /plans/{code}calls. - 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).
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.
{ "name": "Thandi Mokoena", "email": "thandi@example.com" }
{ "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.
{ "name": "Tutor monthly", "amountCents": 19900, "interval": "month", "trialDays": 0 }
{ "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.
{
"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.
{
"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:
{
"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.
{
"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"
}
{
"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}:
{
"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 POSTs 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:
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.
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:
{ "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.
{
"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":
PUT /subscribers/{tutorProUserId}with the current name and email.- If that user already has a subscription that is not
cancelled, stop and show the current one. POST /subscriptionswith a new idempotency key stored before the call. For the trial plan the response status istrialing. Otherwise it isincomplete.- Save the subscription id and send the browser to
checkoutUrl. - If the response status is
trialing, grant access now. If it isincomplete, leave the paid feature locked. On the return page, show that confirmation is in progress andGETthe subscription. Grant a paid start only when thatGETshowsactive, or whensubscription.activatedarrives. - When
subscription.activatedarrives, setbilling_statustoactiveand store the period end. A renewal of a subscription that is alreadyactivedoes not send this event. - When
subscription.past_due,subscription.unpaid, orsubscription.cancelledarrives, set that status and lock the paid feature. - A cancel button calls
POST /subscriptions/{id}/cancel. If the response hascancelAtPeriodEnd: true, tell the buyer they keep access untilcurrentPeriodEnd.
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
trialingcreate response. A paid start grants access only aftersubscription.activatedor aGETthat showsactive. - Returning to the success URL with no webhook and no matching
GETleaves access unchanged. past_due,unpaid, andcancelledlock the paid feature, including wheninvoice.paidfor 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.pingreturns 200.- The product does not call Paystack,
/admin,/console,/gateway, or any route this document does not list.