Give an agent this URL

https://docs.paystreams.co.za/integrate.md

This page is the same document, for a person. Raw markdown.

Integrate a product with PayStream

As of 2026-11-10, after an immediate cancel is refused once its invoice is paid, and after a paid incomplete subscription is left in place, and after subscription revisions, subscriber routes, and one current subscription per subscriber.

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 and ServicePro are the worked examples. 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 plan prices, the seat counts, 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. When PayStream returns a checkoutUrl, send the buyer there. A trial returns none at signup. 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 a GET showing active, for the current subscription. Remove access when that subscription's later webhooks, or a GET, show past_due, unpaid, cancelled, or expired.
  4. Access follows the current subscription's status in the table below. trialing and active may use the paid feature. Every other status may not.
  5. You may key every subscription call by your own subscriber id. Keep the subscription id on the local row for that subscription. You do not have to send it. There is no list route, so store an invoice id, or take it from invoice.paid or openInvoiceId.
  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 a plan, for one or more seats. It bills the plan's price per seat, as the plan stands at each renewal, and keeps the interval it started with.
Seat One unit of whatever the product sells by, such as a site. PayStream holds a count, not what a seat is.
Invoice A subscription charge, or a one-off platform charge. Status is open, paid, refund_pending, refunded, or void.
Checkout A PayStream-hosted page. A link works for 24 hours. A trial's card-setup link works until the trial ends.

PayStream does not offer plan changes, usage metering, coupons, pauses, partial refunds, account credits, a customer portal, or a second currency. Seats are covered under "Seats" below. To move a buyer onto another plan, the current subscription has to reach cancelled or expired first. A create while one is live is 409 Subscription exists and starts nothing. An incomplete one is replaced. See "Start a subscription".

Who may use the paid feature

Store status from the current subscription and gate the feature with this table. See "Keep a copy of the subscription".

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. No card is needed.
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 Someone chose to end it: the buyer or an operator cancelled, the period ended after a scheduled cancellation, or PayStream accepted a refund of a subscription invoice.
expired No A free trial reached its end with no card and nobody cancelled it. This is final.

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.

Adding a card to a trial takes an R1.00 card-setup charge (100 cents, unless an operator has changed it), refunded automatically. It is not revenue and it creates no invoice. When the card is stored, subscription.updated is sent. There is no invoice.paid. The buyer may see that rand, and its refund, on their statement. Say so wherever the product asks for the card. Card setup is still only for a trial.

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. A subscriber action with no current subscription is 404 with title No current subscription.
409 A concurrent duplicate of an idempotent create, a second live subscription (Subscription exists), a lost create race (Conflict), an immediate cancel whose invoice was just paid (Conflict), a retired plan, a refund PayStream will not attempt, a seat change the subscription cannot take or whose quote is out of date, a card setup on a subscription that cannot take one, or a PUT /plans/{code} price or interval change on a plan with live subscriptions.
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 /subscriptions/{id}/seats, POST /subscribers/{externalId}/subscription/seats, 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. A seat-change key is shared by the two seat routes. Once the subscription has ended, replaying that key on the subscriber route is 404 No current subscription, not the stored response. POST /subscribers/{externalId}/subscriptions takes no key. 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. @ is decoded. A slash is not: send it as %2F, and PayStream keeps those three characters. PUT /subscribers/user%2F1 stores the id user%2F1. Every later /subscribers/{externalId} route, including the subscription routes, must use that same encoded 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.

The plan's amountCents is the price per seat, and it is the price every renewal charges. A subscription copies interval at start and keeps it. The amount is read from the plan again at each renewal, so a change to it reaches existing subscriptions from their next renewal. The period already paid is not repriced, and a yearly subscription sees the change at its next annual renewal. A price decrease works the same way, and so does a retired plan: subscriptions on a retired plan keep renewing, at the plan's current price. The first charge after a trial uses the price at the trial end.

PUT /plans/{code} returns 409, title Plan has live subscriptions, when it would change the amountCents or the interval of a plan that has live subscriptions (trialing, active, past_due or unpaid; an abandoned incomplete one does not count). It still changes the name and the trial length, and it sets any price or interval on a plan with no live subscriptions. A price change on a live plan is a confirmed action by an operator in the console, which shows how many subscriptions it reaches.

The product warns its own subscribers before a price rise. PayStream sends no price-change email and holds no pending price or effective date: the change applies from each subscription's next renewal, as soon as the operator confirms it. Warn the subscribers first, and have the operator make the change after the notice has run. Check whether South African consumer rules set a minimum notice period for a price change on a recurring service, and allow for it.

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"
}

seats is optional, from 1 to 10000, and defaults to 1. The first invoice is the plan price times seats. Both return URLs must be absolute https, or http only when the host is localhost. They are optional when the buyer will start a free trial, because no checkout is opened. Send them anyway unless you know the buyer has never had a trial: without them, a buyer who already had one gets a 400. 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, checkoutUrl is null, and the buyer enters no card. Grant access and take them into the product. The first real period is period 0, anchored at the trial end. subscription.activated arrives only when that first period is paid.

A subscriber gets one trial on this product. Anyone who has had one, however it ended, starts as incomplete with a checkoutUrl, even on a plan with a trial. That is how a buyer whose trial expired comes back: POST /subscriptions with a new idempotency key and send them to the checkoutUrl. Check the status in the response. Do not assume a plan with a trial returns trialing.

Add a card during a trial

PayStream emails the buyer a card link 3 days and 1 day before the trial ends, so the product does not have to do anything. A trial too short for those dates gets one email halfway through. To offer an "Add card" button as well, call this from the product's server when the buyer presses it:

POST /subscriptions/{id}/card-setup

{
  "successUrl": "https://tutorpro.example/billing/return?outcome=success",
  "cancelUrl": "https://tutorpro.example/billing/return?outcome=cancel"
}
{ "checkoutUrl": "https://api.paystreams.co.za/checkout/4c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f" }

HTTP 200. Both return URLs are required. Send the buyer to checkoutUrl. The link works until the trial ends. The status is 409 if the subscription is not trialing, is set to cancel, or already has a card. The page tells the buyer what they will be charged and when.

When a trial ends:

At the trial end Result
A card is on file The full price is charged. A successful charge makes it active, with subscription.activated. A failed one makes it past_due.
No card, and nobody cancelled expired, with subscription.expired and no charge or invoice.
Cancelled during the trial, with or without a card cancelled, with subscription.cancelled and no charge.

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,
  "seats": 1,
  "seatsAtRenewal": 1,
  "revision": 4
}

amountCents is the price per seat for the current period. seats is what the subscription has paid for now, and seatsAtRenewal is what the next renewal bills. 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. revision is the stream version after the events that produced this object. See "Keep a copy of the subscription".

A subscriber can have one subscription that has not ended. POST /subscriptions with a new idempotency key:

The same idempotency key still returns the original response. A 409 is not stored, so the same key can be retried after the subscriber's situation changes. A create that loses a race gets 409 with title Conflict, not Subscription exists. Retry, or GET the subscriber's subscription.

If the buyer left and the 24-hour link has expired, the subscription is still incomplete. POST /subscriptions with a new idempotency key replaces it and returns a new checkoutUrl. POST /subscribers/{externalId}/subscriptions for the same plan returns that same subscription and a working checkoutUrl, including a new link for the same first invoice when the old one has expired. Cancelling the incomplete subscription first, then posting, still works on either route, and that cancel step is optional. If a GET shows active or trialing, send them into the product instead.

Keep a copy of the subscription

Every subscription object PayStream returns or sends includes revision: GET, a response that contains subscription, and the subscription inside every webhook. It is the version of that subscription's event stream after the events that produced the object. It only goes up. The numbers are not consecutive. An event can move revision without changing a field you store. Opening a seat-addition checkout moves it, and that commit sends a subscription webhook only when it also ended an earlier addition.

Keep one row per subscription id you have seen for that subscriber, or at least remember those ids. On the row for an id, store the revision of the object you last applied. Apply any subscription.* object to the row with that id only when its revision is greater than or equal to the revision stored for that id. The same revision is the same object, so applying it again changes nothing. Ignore a lower revision for that id.

subscription.created is the revision-1 object for its own id. Apply it under that same rule. It does not write onto a different id's row, and it does not overwrite a higher revision of its own id. A delivery can arrive late, and a failed delivery is retried, so created can show up after activated for the same id. The higher revision stands.

The subscriber's current subscription is the one that has not ended. Not ended means the status is not cancelled or expired. PayStream keeps at most one of those at a time. Delivery is not ordered, so two rows can look not-ended locally for a while. A replaced incomplete subscription's subscription.cancelled can arrive after the new one's subscription.created. When you see more than one not-ended row for a subscriber, or a subscription.created for an id you have not stored, call GET /subscribers/{externalId}/subscription and treat that answer as the current subscription. Apply that object to its own id under the same revision rule. Grant or remove access from the current subscription. A webhook for another id leaves it as it is.

One commit can send two subscription webhooks with that same revision and the same object. PayStream does not promise which arrives first. The webhooks queue delivers in parallel. Apply the object from either of them, and handle each event type on its own. A renewal that drops seats and has no card sends subscription.seats_changed and subscription.past_due together. Both describe the subscription that commit left behind.

subscription.updated is the change that has no type of its own. It is sent only when that commit sends no other subscription.* type. A card stored, a cancellation scheduled, a seat decrease booked for the next renewal, a renewal invoice opened when the seat count did not fall, and a renewal paid for a subscription that was already active are this type. A commit that sends activated, past_due, unpaid, cancelled, expired, trial_ending, seats_changed, or seat_change_failed does not also send updated.

A product handles every subscription.* type the same way: store the object on the row for its id under the rule above, then grant or remove access from the current subscription. invoice.paid carries an invoice, not a subscription, and it has no revision. Handle it on its own.

Every webhook data except test.ping has a top-level subscriberId. That is your external id, the same value as subscription.subscriberId and invoice.subscriberId.

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, one_off or seat_addition. status is open, paid, refund_pending, refunded or void. 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, and neither can a seat_addition invoice.

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.

A link stops working 24 hours after it was created, or at the trial end for a trial's card-setup link. The page then says so. A payment the gateway has already taken is still recorded if the notification arrives after that, except on a seat-addition checkout: expiry ends the addition, so that payment is refunded and no seat is added. A card-setup payment confirmed after the trial is over is refunded, and its card is not kept.

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, or wait for subscription.updated, if the screen needs to say the card is on file. That confirmation can land after the redirect. There is no invoice.paid for the card setup. Keep granting trial access from status trialing while card is null.

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.

Seats

A subscription covers one or more seats on one card and one renewal date. Each renewal bills the plan's price times seatsAtRenewal. A plan that never asks for more than one seat bills as it always did.

Quote

GET /subscriptions/{id}/seats/quote?seats=3 returns what changing to that count would charge now, so the product can show the amount before the buyer confirms.

{ "seats": 3, "amountCents": 7500 }

amountCents is 0 when nothing would be charged: a decrease, a count at or below seats, a trial, or a charge under R1.00. seats outside 1 to 10000 is 400. A subscription that cannot take a seat change is 409.

Change

POST /subscriptions/{id}/seats with Idempotency-Key.

{ "seats": 3, "quotedAmountCents": 7500, "successUrl": "https://tutorpro.example/billing/return?outcome=success", "cancelUrl": "https://tutorpro.example/billing/return?outcome=cancel" }

seats is the count wanted, not the number to add, from 1 to 10000. A count outside that range is 400. quotedAmountCents is the amount the buyer was shown and is required when the request would charge. If it is missing or no longer matches, the response is 409 with title Quote out of date and nothing is charged. successUrl and cancelUrl are needed only when the subscription has no stored card and the request charges, and follow the same rules as POST /subscriptions. Without them that request is 400. The body is { "subscription": Subscription, "invoiceId": "…" or null, "checkoutUrl": "…" or null }.

Request Result
A count above seats, with a charge 202. Only the added seats are charged, for the rest of the period: the price per seat times seats added times days remaining over days in the period, with days rounded up to whole days and the result rounded half up to the cent. R150 with 15 of 30 days left and one seat added is R75.00. invoiceId is the seat_addition invoice. The seats are not added until it is paid.
A count above seats, with a charge under R1.00 (100 cents) 200. The seats are added at once, free, and subscription.seats_changed is sent.
A count below seats 200. No money moves. seatsAtRenewal becomes the count and seats stays, for example seats: 3, seatsAtRenewal: 2. The buyer keeps the seats until the renewal, with no refund.
A count above seatsAtRenewal and at or below seats 200. Undoes a pending decrease. No charge, because those seats are already paid for.
A trial 200. seats and seatsAtRenewal both change at once, with no charge, and subscription.seats_changed is sent.
Below 1 400. Going to zero is a cancellation.

A 202 with a stored card charges that card off session, once, with no retry schedule. A 202 with no stored card returns a checkoutUrl for the invoice, valid for 24 hours. A card used on that checkout is not stored, so a buyer paying by instant EFT is not moved to card renewals. A declined attempt on the page can be tried again until the link expires.

A change is refused with 409 when the subscription is not active or trialing, when cancelAtPeriodEnd is true, when a renewal invoice is open (openInvoiceId is set), or when a card charge for an earlier seat addition is still in progress. An addition waiting on a checkout does not block: a new request ends it (reason replaced, its checkout is closed) and starts the new one. On an active subscription, a decrease or an undo sends subscription.updated. If that request also ends an addition waiting on a checkout, the commit sends subscription.seat_change_failed and does not also send subscription.updated. The object still shows the new seatsAtRenewal. A trial change sends subscription.seats_changed. Opening a charge sends no subscription webhook of its own.

Outcome of a charged addition

Outcome What happens
Paid seats and seatsAtRenewal rise together, a tax invoice is issued, then invoice.paid (kind: "seat_addition") and subscription.seats_changed are sent. The billing period is unchanged and no renewal is scheduled.
The stored card was declined, the checkout link expired, a renewal came due first, or the subscription was cancelled The seats do not change. The invoice becomes void. subscription.seat_change_failed is sent with reason and invoiceId. The subscription does not go past_due, there is no retry and no dunning mail. Seats already paid for stay in use.
The card charge is pending Nothing changes. PayStream checks again each hour until the charge resolves, and does not call a pending charge a failure. If a renewal or cancellation ends the addition first, the invoice becomes void and the checks continue for up to 7 days after that. A charge that then succeeds is refunded and adds no seat.

void appears only on seat_addition invoices that will never be paid. A payment that arrives for an addition PayStream has already ended is refunded.

The reason on subscription.seat_change_failed is replaced (a new request or a decrease ended an addition waiting on a checkout), checkout_expired, renewal, cancelled, or the gateway's own failure text for a declined card. That text varies, so do not switch on it: treat any value outside the four named ones as a gateway failure.

At renewal

The renewal invoice is the plan's current price times seatsAtRenewal, with the seat count in the description when it is above 1, for example Tutor monthly x 3. When the count has fallen, that renewal sends subscription.seats_changed. The seat drop is not silent. The product closes the seat it chose when it receives that event, or when GET shows seats has fallen. PayStream does not know which site or user a seat belongs to. A renewal that does not drop seats, and that does not also send past_due or seat_change_failed, sends subscription.updated when the invoice opens.

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, when that invoice is still open. Open checkout links for it stop working.
cancelled or expired The same object, unchanged.

A 409 with title Conflict means a payment just landed on the invoice that cancel would discard. The subscription was not cancelled. GET the subscription and retry. Once it is active, cancel schedules the end of the period. POST /subscribers/{externalId}/subscription/cancel does the same.

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

By subscriber id

These routes take your external id. A slash in that id is %2F in the path, as on PUT /subscribers. Each action uses the subscription that has not ended. When there is none, the action is 404 with title No current subscription, and that is decided before the route's own rules. A bad seat count or a bad card-setup URL is still that 404. A seat-change Idempotency-Key that already succeeded, sent again after the subscription has ended, is that 404 and not the stored response.

GET /subscribers/{externalId}/subscription returns the subscription object, including revision.

The subscriber Response
Does not exist 404, title Subscriber not found.
Exists, and has never had a subscription 404, title No subscription.
Has one that has not ended 200, that subscription.
Every subscription has ended 200, the most recently started one.

POST /subscribers/{externalId}/subscriptions creates. No Idempotency-Key. Body: { "planCode", "seats"?, "successUrl"?, "cancelUrl"? }, with the same field rules as POST /subscriptions. A 201 body is { "subscriptionId", "status", "checkoutUrl", "subscription" }. A 200 body is the same shape.

What they already hold Response
Subscriber does not exist 404, title Subscriber not found.
Nothing current (none, or only ended) 201. The same start as POST /subscriptions, plus subscription.
Current subscription on the same plan, incomplete, first invoice still open 200. checkoutUrl is the open checkout. If that checkout has expired, PayStream opens a new one for the same first invoice. Return URLs on this request are used. When this request omits them, the expired checkout's URLs are kept. seats on this request is ignored.
Current subscription on the same plan, incomplete, first invoice already paid 200. checkoutUrl is null. The subscription stays incomplete until subscription.activated. seats is ignored.
Current subscription on the same plan, live (trialing, active, past_due, unpaid) 200. checkoutUrl is null. seats is ignored.
incomplete on a different plan, first invoice still open 201. The old one is cancelled and this one starts. successUrl and cancelUrl are required unless the new plan starts a trial, and without them the response is 400.
incomplete on a different plan, first invoice already paid 409, title Conflict. The old subscription is not cancelled.
Live on a different plan 409, title Subscription exists, with the existing subscription in the body.
Plan missing 404, title Plan not found.
Plan retired 409, title Plan retired.
Body fails the field rules 400, title Invalid subscription, as POST /subscriptions.

A create that loses a race is 409 with title Conflict, not Subscription exists. The same title is the response when a create would replace an incomplete subscription whose first invoice is already paid. Retry, or GET the subscriber's subscription.

POST /subscribers/{externalId}/subscription/card-setup is the trial card setup. It is still refused unless the current subscription is trialing, not set to cancel, and has no card (409, title Card setup not available). Both return URLs are required once a current subscription exists.

GET /subscribers/{externalId}/subscription/seats/quote?seats=n and POST /subscribers/{externalId}/subscription/seats are the seat quote and the seat change, on the current subscription. The change still requires Idempotency-Key. That key is the same key as POST /subscriptions/{id}/seats for that subscription. Seats on an incomplete subscription are 409 Seats cannot change.

POST /subscribers/{externalId}/subscription/cancel is the cancel, on the current subscription. An empty body, no idempotency key, and the same results as POST /subscriptions/{id}/cancel.

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 a seat addition, 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.

A seat charge refunded directly at Paystack is different from this route: PayStream issues the credit note and marks the invoice refunded, and leaves the subscription and its seats unchanged until the owner changes them.

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

PayStream does not promise which webhook arrives first. invoice.paid and subscription.activated are delivered on separate workers, and the webhooks queue runs in parallel, so either of two webhooks from one commit can arrive first. Handle each on its own. When two subscription webhooks share a revision, apply the object from either, on the row for that subscription id. See "Keep a copy of the subscription". Grant or remove access from the current subscription, the one that has not ended.

When subscription.activated, subscription.updated, or any other subscription.* names an id the product has never stored, create the row from the object, then GET /subscribers/{externalId}/subscription to confirm which subscription is current.

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 subscription.updated, and does not send subscription.activated. Leave access in place and store the new period from that object.

Every data below except test.ping has a top-level subscriberId, your external id.

Type data What the product does
invoice.paid { "subscriberId", "invoice": Invoice } Record the payment. For kind: "one_off", grant what that invoice bought. For kind: "subscription" or "seat_addition", record the invoice. Access still comes from the subscription status.
subscription.created { "subscriberId", "subscription": Subscription } Store the object on the row for its id when its revision is greater than or equal to the stored one. It does not replace another id. The subscription exists even if the create response was lost.
subscription.updated { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. This is a change with no more specific type.
subscription.activated { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. Grant access when that subscription is the current one.
subscription.past_due { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. openInvoiceId is set. Remove access when that subscription is the current one.
subscription.unpaid { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. Remove access when that subscription is the current one.
subscription.cancelled { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. Remove access when that subscription is the current one. cancelAtPeriodEnd is already over.
subscription.seats_changed { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. Make seats usable. When seats has fallen, close the seats the product chose to drop.
subscription.seat_change_failed { "subscriberId", "subscription": Subscription, "reason": string, "invoiceId": string } The seats you asked for were not added and seats is unchanged. Tell the buyer. The subscription's status is unaffected.
subscription.trial_ending { "subscriberId", "subscription": Subscription } Optional. Sent with each reminder email while a trial has no card. Show an "Add card" prompt. Access does not change.
subscription.expired { "subscriberId", "subscription": Subscription } Store the object on its id under the revision rule. Remove access when that subscription is the current one. This event is required: a product that ignores it leaves the buyer with access after the trial.
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 when the cancelled subscription is the current one.

{
  "id": "6f1e2d3c-4b5a-6978-8012-abcdef012345",
  "type": "subscription.activated",
  "created": "2026-11-09T08:00:12+00:00",
  "data": {
    "subscriberId": "user_8f3a",
    "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,
      "seats": 1,
      "seatsAtRenewal": 1,
      "revision": 4
    }
  }
}

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 these cases. The two unpaid-renewal mails 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
A trial with no card is about to end, with a card link {Product}: your {plan} trial ends on {date}
A trial ends with no card and becomes expired {Product}: your {plan} trial has ended

The first checkout, a receipt, a refund, a cancellation, a completed 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} or GET /subscribers/{externalId}/subscription 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. Do the same for a local row still marked trialing once its trialEndsAt has passed, so a missed subscription.expired cannot leave the buyer with access. Apply the object when its revision is greater than or equal to the one you stored for that subscription.

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 one row per subscription id for the user, server side. The current subscription is the row whose status is not cancelled or expired.

Column Source
paystream_subscription_id subscription id for that row. Optional on requests if you call the subscriber routes instead.
billing_status subscription status
plan_code subscription planCode
revision subscription revision
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 the current row's 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. POST /subscriptions with a new idempotency key stored before the call. On the trial plan the response status is trialing, unless this user has already had a trial. Otherwise it is incomplete. A 409 Subscription exists means a live subscription is already there: show the subscription in that body and stop. An incomplete one whose first invoice is still open is replaced; use the new checkoutUrl. One whose first invoice is already paid is 409 Conflict: leave it in place and wait for subscription.activated.
  3. Save the subscription id. If the response has a checkoutUrl, send the browser there.
  4. If the response status is trialing, grant access now and take the user into TutorPro. 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.
  5. When subscription.activated arrives, apply it under "Keep a copy of the subscription". When that subscription is the current one, set billing_status to active and store the period end. A renewal of a subscription that is already active sends subscription.updated, not subscription.activated. Apply that object the same way, and store the new period end.
  6. When subscription.past_due, subscription.unpaid, subscription.cancelled, or subscription.expired arrives, apply it under "Keep a copy of the subscription". Lock the paid feature when that subscription is the current one.
  7. A cancel button calls POST /subscriptions/{id}/cancel. If the response has cancelAtPeriodEnd: true, tell the buyer they keep access until currentPeriodEnd.

Sequence for "add card" during a trial: POST /subscriptions/{id}/card-setup and send the browser to its checkoutUrl.

Sequence for "try again" after an unfinished first checkout: POST /subscriptions with a new idempotency key. That replaces an incomplete subscription. Cancelling it first still works. If the response is 409 Subscription exists, the buyer already has a live subscription.

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.

ServicePro

ServicePro sells software to a business. The business id is the subscriber id. PayStream sells none of the software. One PayStream product, servicepro, bills every ServicePro business. ServicePro keeps one row per subscription id and calls PayStream with the business id. It does not send a PayStream subscription id.

Store one row per subscription id for the business, server side. The current subscription is the row whose status is not cancelled or expired.

Column Source
subscription_id subscription id for that row. Not sent on any request.
billing_status subscription status
plan_code subscription planCode
revision subscription revision
seats subscription seats
seats_at_renewal subscription seatsAtRenewal
current_period_end subscription currentPeriodEnd
cancel_at_period_end subscription cancelAtPeriodEnd
card_last4 subscription card.last4

Gate the paid product on the current row's billing_status of trialing or active. seats is how many sites are usable now. seats_at_renewal is how many the next renewal bills.

Suggested plan, created once with PUT or in the console: code site-monthly, R150.00 (15000), interval month, trial 0. seats on the create is the number of sites.

Sequence for "subscribe":

  1. PUT /subscribers/{businessId} with the current name and email. If the business id contains a slash, the path segment keeps it as %2F.
  2. POST /subscribers/{businessId}/subscriptions with { planCode, seats, successUrl, cancelUrl }. No idempotency key.
  3. On 201, store the subscription from the body, including revision and id. If checkoutUrl is set, send the browser there. If status is trialing, grant access now.
  4. On 200, this business already has this plan. Store the object on the row for its id when its revision is greater than or equal to the revision stored for that id. An incomplete body has a working checkoutUrl, unless the first invoice is already paid, in which case checkoutUrl is null. A live one does not: send them into the product when the status allows it.
  5. On 409 Subscription exists, a live subscription is on another plan. Show the subscription in that body. Do not start another.
  6. On 409 Conflict, the create lost a race. GET /subscribers/{businessId}/subscription and apply that object to its own id under the revision rule.
  7. When any subscription.* webhook arrives for this subscriberId, apply it to the row with that subscription id when its revision is greater than or equal to the revision stored for that id. A subscription.created is revision 1 for its own id. It does not replace another id's row, and it does not overwrite a higher revision. If more than one row has not ended, or the created is for an id you have not stored, GET /subscribers/{businessId}/subscription and treat that answer as the current subscription. Then set access from the current row's billing_status.
  8. When subscription.seats_changed arrives and seats has fallen, close the sites ServicePro chose to drop. A seat drop at renewal is this event. It is not silent.
  9. A cancel button calls POST /subscribers/{businessId}/subscription/cancel.
  10. A seat change calls GET .../subscription/seats/quote?seats=n, shows amountCents, then POST .../subscription/seats with a new Idempotency-Key and that quotedAmountCents.

Sequence for "add card" during a trial: POST /subscribers/{businessId}/subscription/card-setup and send the browser to its checkoutUrl. A subscription that is not trialing gets 409 Card setup not available. Card setup is still trial-only.

Sequence for "try again" after an unfinished first checkout: POST /subscribers/{businessId}/subscriptions with the same plan. That returns the incomplete subscription and a working checkoutUrl. Cancelling it first is optional.

Webhook route, for example POST /api/paystream/webhook. Verify the signature, store the event id, return 200, then apply "Keep a copy of the subscription". Unknown types return 200.

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

Done when