Connect your store or app to Pexally Flow
Show your Flow products and plans on your own website, send buyers to a secure checkout with their plan and email already filled in, and keep your database in step with every payment, renewal, refund and cancellation — automatically.
Base URL: https://flow.pexally.com/api/v1
How it fits together
Your catalog lives in Flow. Your site shows it. The card is taken on Pexally’s secure checkout, because Pexally is the merchant of record for every payment. Your server hears about everything that happens, and asks the API for the details.
- 1Read your catalogGET /products gives your live products and SaaS plans with their prices, so your pages can show them.
- 2Send a buyer to payPOST /checkout-links makes a checkout for one buyer — plan chosen, email filled in, your own customer id attached — and returns a URL to redirect them to.
- 3Hear what happenedA signed webhook tells your server that an order was paid, a subscription started, renewed, failed, was refunded or ended, or a product changed.
- 4Ask for the detailsYour server reads the order, subscription or product from the API and updates its own records. Nothing to poll.
ref. It is stored on the checkout, the order and the subscription, comes back in every webhook, and you can look things up by it. Pexally never shows it to the buyer.Quick start
About fifteen minutes from nothing to a working buy button. You need a Pexally Flow workspace on a plan with the Developer API (Enterprise), and to be its owner or an admin.
Create an API key
In Pexally Flow, open Developers in the sidebar and, under API keys, name a key after the server that will use it — “production”, “staging” — and press Create a key. Copy it now: it is shown once, and we keep only a hash of it.
Keep it on your server as an environment variable, never in your code:
PEXALLY_API_KEY=pxf_…your key…Make your first call
List your live products and plans. A 200 with your catalog means the key works.
curl https://flow.pexally.com/api/v1/products \
-H "Authorization: Bearer $PEXALLY_API_KEY"{
"object": "list",
"data": [
{ "id": "3f6c…", "name": "Design Care", "pricing_mode": "recurring",
"plans": [ { "id": "9a1d…", "name": "Care+", "prices": [ … ] } ] }
],
"has_more": false
}Add a webhook
Still on Developers, under Webhooks, give us an https address on your server — https://yourapp.com/webhooks/pexally — and copy the signing secret it shows you, once.
PEXALLY_WEBHOOK_SECRET=whsec_…your secret…Send a buyer to pay
When somebody presses your buy button, make a checkout link on your server and redirect them to its url. Pass your own id for them as ref — it comes back on the order, the subscription and every webhook.
app.post("/buy", async (req, res) => {
const response = await fetch("https://flow.pexally.com/api/v1/checkout-links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PEXALLY_API_KEY}`,
"Content-Type": "application/json",
// The same key for the same cart: a retry returns the first link.
"Idempotency-Key": `cart_${req.body.cartId}`,
},
body: JSON.stringify({
product_id: "3f6c…", // from GET /products
plan_id: "9a1d…", // a SaaS plan, if it has plans
interval_months: 12, // one of that plan's prices
ref: req.user.id, // your id for this buyer
customer_email: req.user.email,
success_url: "https://yourapp.com/welcome",
}),
});
const link = await response.json();
if (!response.ok) return res.status(502).send(link.error.message);
res.redirect(303, link.url); // Pexally's secure checkout
});Give them what they paid for
Pexally tells your webhook the moment the payment lands. Check the signature, then ask the API for the details and update your own records.
import crypto from "node:crypto";
import express from "express";
app.post("/webhooks/pexally", express.raw({ type: "application/json" }), async (req, res) => {
const timestamp = req.get("Pexally-Timestamp");
const expected = "v1=" + crypto
.createHmac("sha256", process.env.PEXALLY_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const presented = req.get("Pexally-Signature") ?? "";
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!fresh || presented.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(expected))) {
return res.sendStatus(400);
}
res.sendStatus(200); // answer first, then work
const event = JSON.parse(req.body);
if (await alreadyHandled(event.id)) return; // deliveries can repeat
const path = event.type.startsWith("subscription.")
? `/subscriptions/${event.ref}`
: `/orders/${event.ref}`;
const details = await fetch("https://flow.pexally.com/api/v1" + path, {
headers: { Authorization: `Bearer ${process.env.PEXALLY_API_KEY}` },
}).then((r) => r.json());
await grantAccess(event.ref, details); // your code
});Try it end to end
There is no separate test mode: keys act on your real workspace, and Pexally takes real cards. Make a low-priced product, buy it yourself through your own button, watch the webhook arrive, then refund it from Finance in Pexally Flow — the refund arrives as order.refunded, which tests that path too.
403 plan_required; webhooks stop being sent, and checkout links you already made keep working for your buyers. Everything starts again as it was the moment the workspace is back on Enterprise.Authentication
Every request carries a workspace API key as a bearer token. Keys start with pxf_, are shown once when created, and can be revoked one at a time — make a separate key for each server, so rotating one is not an outage.
Authorization: Bearer pxf_your_keyA key is read only or has full access, chosen when you make it. Read only lists products, orders and subscriptions — right for a storefront that only shows things. Full access can also make checkout links and cancel subscriptions. A read-only key that tries either gets 403 read_only_key.
A missing, wrong or revoked key gets the same 401 — telling them apart would confirm which keys are real. A real key on a workspace whose plan no longer includes the API gets 403 plan_required instead, so you know to look at the plan and not at the key.
Conventions
| Format | JSON in and out. Send Content-Type: application/json with a body. |
| Money | Integer cents as numbers — 14900 is $149.00 — with currency: "USD". Every price on Pexally is in US dollars. |
| Times | ISO 8601, UTC: 2026-09-14T10:00:00Z. |
| Lists | { "object": "list", "data": […], "has_more": true, "next_cursor": "…" }. Pass next_cursor back as starting_after for the next page. limit is 1–100, default 25. |
| Idempotency | Send Idempotency-Key on a POST that makes something. A retry with the same key returns the first result instead of a second one. |
| Caching | Every response is Cache-Control: no-store. Cache entitlements yourself for about a minute if you check on every page. |
Products
Your live catalog: one-time products, and SaaS listings with their plans. A draft or an archived product is not in it — a product appears once Pexally has approved it.
/productsEvery live product, oldest first. Not paginated — a catalog is small.
/products/{id}One live product. 404 for a draft, an archived product or an id that is not yours.
{
"id": "3f6c2c1e-…",
"object": "product",
"name": "Design Care",
"description": "A retainer: small changes, quick turnarounds.",
"type": "digital_download",
"type_label": "Retainer",
"pricing_mode": "recurring",
"price_cents": null,
"interval_months": null,
"currency": "USD",
"sold_out": false,
"image_url": null,
"plans": [
{
"id": "9a1d44b0-…",
"name": "Care+",
"tagline": "Everything in Care, and a standing weekly slot.",
"highlighted": true,
"features": ["Everything in Care", "A standing hour every week"],
"prices": [
{ "interval_months": 1, "price_cents": 120000, "currency": "USD" },
{ "interval_months": 12, "price_cents": 1296000, "currency": "USD" }
]
}
],
"checkout_url": "https://pay.pexally.com/acme/saas/HdcZ…",
"created_at": "2026-09-01T09:00:00Z",
"updated_at": "2026-09-14T10:00:00Z"
}image_url is the product’s picture — a public address you can show as-is — or null when none has been set. A one-time product has price_cents and an empty plans. A listing with plans has its prices on the plans, one per billing cycle, and price_cents is null. checkout_url is the product’s public page — use a checkout link when you want the plan chosen and the email filled in.
Checkout links
Your own product pages and buy buttons, with the payment on Pexally’s secure checkout. Make a link on your server when the buyer presses buy, then redirect them to it. Card details never touch your servers or ours — they go straight to the payment processor.
/checkout-links| Field | Description |
|---|---|
| product_id | Required. A live product. |
| plan_id | Required for a product with plans; leave it out otherwise. |
| interval_months | The billing cycle, for a plan: 1 for monthly, 12 for yearly. Defaults to the plan's shortest cycle. |
| ref | Your id for this buyer — up to 200 characters. Stored on the order and subscription, and in every webhook. |
| customer_email | Filled into the receipt box. The buyer can change it. |
| success_url | An https:// address. After paying on the page, the buyer sees a “Continue” button back to it. |
{
"id": "5b0e…",
"object": "checkout_link",
"url": "https://pay.pexally.com/acme/checkout/Qm7x…",
"product_id": "3f6c…",
"plan_id": "9a1d…",
"interval_months": 12,
"ref": "user_1234",
"customer_email": "[email protected]",
"success_url": "https://yourapp.com/welcome",
"expires_at": "2026-09-15T10:00:00Z",
"created_at": "2026-09-14T10:00:00Z"
}- A link lasts 24 hours. After that the page tells the buyer to go back and start again — make a fresh link each time somebody presses buy.
- The link carries no price. The price is read from your product when the buyer pays, so changing a price in Flow takes effect at once.
- A plan that costs nothing cannot have a checkout — there is nothing to pay. Sign those customers up on your own site.
- The “Continue” page is not proof of payment — anyone can open that address. Grant access when the webhook arrives.
?ref=your_id, and the ref is carried through exactly the same way.Orders
A payment: a one-time purchase, or one charge of a subscription (the first and every renewal). amount_cents is what the buyer paid.
/ordersEvery product and SaaS payment, newest first. Filters: limit, starting_after, created_after, ref.
/orders/{ref}Every payment carrying your ref, newest first. An empty list, never a 404, when there are none.
{
"id": "c1f0…",
"object": "order",
"ref": "user_1234",
"status": "paid",
"product": { "id": "3f6c…", "name": "Brand Starter Kit" },
"amount_cents": 14900,
"refunded_cents": 0,
"currency": "USD",
"provider": "stripe",
"buyer_email": "[email protected]",
"subscription_id": null,
"created_at": "2026-09-14T10:02:11Z"
}Subscriptions
A buyer on a repeating plan. trialing, active and past_due all still have access — past due means a card failed and Pexally is retrying it. cancelled and expired do not.
/subscriptions/{ref}The entitlement check. Every subscription carrying your ref, the most entitling first — read data[0] to decide whether somebody is in.
/subscriptionsEvery subscription, newest first, for backfilling or a nightly reconcile. Filters: limit, starting_after, created_after, ref, status.
/subscriptions/{id}/cancelFor your own “cancel my plan” button. It stops at the end of the period already paid for: until then the subscription stays active with cancel_at_period_end: true, and subscription.cancelled arrives when it actually ends. Calling it twice is harmless.
{
"id": "e27a…",
"object": "subscription",
"ref": "user_1234",
"status": "active",
"product": { "id": "3f6c…", "name": "Design Care" },
"amount_cents": 120000,
"unit_price_cents": 120000,
"quantity": 1,
"currency": "USD",
"interval_months": 1,
"current_period_start": "2026-09-14T10:02:11Z",
"current_period_end": "2026-10-14T10:02:11Z",
"cancel_at_period_end": false,
"subscriber_email": "[email protected]",
"created_at": "2026-09-14T10:02:11Z",
"ended_at": null
}Webhooks
Pexally sends a small signed POST to your https address when something changes. It says what changed and never the details — read those from the API. So if a delivery is ever missed, the next read puts you right.
| Event | When |
|---|---|
| order.paid | A one-time purchase was paid. Carries order_id, ref, product_id. |
| order.refunded | Money went back on a one-time order, or it was charged back. |
| subscription.started | Somebody subscribed. Carries subscription_id, ref, product_id. |
| subscription.renewed | A renewal was paid and the period moved forward. |
| subscription.payment_failed | A renewal charge failed and Pexally is retrying. |
| subscription.cancelled | It ended — by cancellation at period end, or by running out. |
| subscription.refunded | Money went back on one of its payments. |
| product.updated | A live product changed — price, plans, name, or it went live or came down. Carries product_id. |
Each webhook hears every event unless you choose some in Developers → Webhooks. “Every event” includes kinds added later. Send a test there delivers a signed webhook.test with no other fields, so you can check your server accepts it — answer it with a 2xx like any other event. A test is never retried.
POST https://yourapp.com/webhooks/pexally
Content-Type: application/json
Pexally-Timestamp: 1789380000
Pexally-Signature: v1=5c7f…
{
"id": "0b8e3c1a-…",
"type": "subscription.started",
"created_at": "2026-09-14T10:02:12Z",
"workspace_id": "008776da-…",
"subscription_id": "e27a…",
"ref": "user_1234",
"product_id": "3f6c…"
}Verify every delivery
The signature is HMAC-SHA256 of {timestamp}.{raw body} with your signing secret — the whole secret, including whsec_ — hex-encoded and prefixed v1=. Use the raw body exactly as received, refuse anything older than five minutes, and compare in constant time.
import crypto from "node:crypto";
export function verifyPexally(rawBody, headers, secret) {
const timestamp = headers["pexally-timestamp"];
const presented = headers["pexally-signature"] ?? "";
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const expected =
"v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return (
fresh &&
presented.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(presented), Buffer.from(expected))
);
}import hmac, hashlib, time
def verify_pexally(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers["Pexally-Timestamp"]
presented = headers.get("Pexally-Signature", "")
if abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(presented, expected)- Answer 2xx quickly, then do the work. Anything else, a timeout after 10 seconds or a redirect is a failure.
- Failures are retried six times over about a day and a half — after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. After 20 failures in a row the webhook is switched off: the workspace’s owners and admins are told, and Developers → Webhooks can send a test and turn it back on with the same signing secret. Nothing is queued while it is off, so catch up from the API.
- Handle each event once. A delivery can arrive twice — store the
idand skip one you have already processed. A message sent again from the delivery log carries the sameidas the first time.
Keeping in sync
A SaaS app
- 1.Show your plans from GET /products, or hard-code them against the plan ids.
- 2.On “Subscribe”, POST /checkout-links with ref = your user id and success_url = your welcome page.
- 3.On any subscription.* event, read GET /subscriptions/{ref} and store status and current_period_end against the user.
- 4.On login, check what you stored — or call GET /subscriptions/{ref} and cache it for a minute.
- 5.Your “Cancel” button calls POST /subscriptions/{id}/cancel.
An online store
- 1.Import GET /products into your catalog, keyed by id. On product.updated, re-read GET /products/{id}.
- 2.On “Buy”, POST /checkout-links with ref = your order or cart id and success_url = your thank-you page.
- 3.On order.paid, read GET /orders/{ref}, mark your order paid and deliver.
- 4.On order.refunded, read it again and withdraw access if refunded_cents covers the order.
- 5.Once a night, page through GET /orders?created_after=… to catch anything missed.
Errors
Every error is { "error": { "code": "…", "message": "…" } }. Branch on code — the message is for a person reading a log and may be improved.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A field or parameter is wrong; the message says which. |
| 400 | invalid_json | The body is not a JSON object. |
| 400 | invalid_reference | The ref is empty or longer than 200 characters. |
| 401 | unauthorized | The key is missing, wrong or revoked. |
| 403 | plan_required | The key is real, but the workspace's plan does not include the Developer API. The key is kept and works again after an upgrade. |
| 403 | read_only_key | The key can only read. Make a key with full access in Developers to create checkout links or cancel subscriptions. |
| 404 | not_found | No such product, plan or subscription in your workspace. |
| 413 | payload_too_large | The body is over 16 KB. |
| 415 | unsupported_media_type | Send Content-Type: application/json. |
| 503 | unavailable | Our side. Retry with backoff. |
Limits
| Plans | The API and webhooks are on the Enterprise plan. |
| API keys | 20 active per workspace. |
| Webhooks | 5 per workspace, https only. |
| Checkout links | Last 24 hours. |
| Lists | Up to 100 per page. |
| ref | Up to 200 characters. |
| Idempotency-Key | Up to 255 characters. |
| Request body | Up to 16 KB. |
Questions? Ask us from Support inside Pexally Flow.