API v1

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.

  1. 1Read your catalogGET /products gives your live products and SaaS plans with their prices, so your pages can show them.
  2. 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.
  3. 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.
  4. 4Ask for the detailsYour server reads the order, subscription or product from the API and updates its own records. Nothing to poll.
The ref is the thread through all of it. Whatever id your system uses for the buyer — a user id, a cart id — send it as 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.

1

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:

.env (your server)
PEXALLY_API_KEY=pxf_…your key…
2

Make your first call

List your live products and plans. A 200 with your catalog means the key works.

Terminal
curl https://flow.pexally.com/api/v1/products \
  -H "Authorization: Bearer $PEXALLY_API_KEY"
You get back
{
  "object": "list",
  "data": [
    { "id": "3f6c…", "name": "Design Care", "pricing_mode": "recurring",
      "plans": [ { "id": "9a1d…", "name": "Care+", "prices": [ … ] } ] }
  ],
  "has_more": false
}
3

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.

.env (your server)
PEXALLY_WEBHOOK_SECRET=whsec_…your secret…
4

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.

Node.js — your buy button's route
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
});
5

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.

Node.js — your webhook route
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
});
6

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.

On a plan without the Developer API, your keys are kept and answer every request with 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.

HTTP
Authorization: Bearer pxf_your_key

A 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.

Server to server only. A key reads everything your workspace has sold. Never put it in a browser, a mobile app or a public repository. The API sends no CORS headers on purpose, so a browser cannot call it.

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

FormatJSON in and out. Send Content-Type: application/json with a body.
MoneyInteger cents as numbers — 14900 is $149.00 — with currency: "USD". Every price on Pexally is in US dollars.
TimesISO 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.
IdempotencySend Idempotency-Key on a POST that makes something. A retry with the same key returns the first result instead of a second one.
CachingEvery 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.

GET/products

Every live product, oldest first. Not paginated — a catalog is small.

GET/products/{id}

One live product. 404 for a draft, an archived product or an id that is not yours.

A SaaS listing
{
  "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.

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.

GET/orders

Every product and SaaS payment, newest first. Filters: limit, starting_after, created_after, ref.

GET/orders/{ref}

Every payment carrying your ref, newest first. An empty list, never a 404, when there are none.

An order
{
  "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.

GET/subscriptions/{ref}

The entitlement check. Every subscription carrying your ref, the most entitling first — read data[0] to decide whether somebody is in.

GET/subscriptions

Every subscription, newest first, for backfilling or a nightly reconcile. Filters: limit, starting_after, created_after, ref, status.

POST/subscriptions/{id}/cancel

For 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.

A subscription
{
  "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.

EventWhen
order.paidA one-time purchase was paid. Carries order_id, ref, product_id.
order.refundedMoney went back on a one-time order, or it was charged back.
subscription.startedSomebody subscribed. Carries subscription_id, ref, product_id.
subscription.renewedA renewal was paid and the period moved forward.
subscription.payment_failedA renewal charge failed and Pexally is retrying.
subscription.cancelledIt ended — by cancellation at period end, or by running out.
subscription.refundedMoney went back on one of its payments.
product.updatedA 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.

A delivery
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.

Node.js
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))
  );
}
Python
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 id and skip one you have already processed. A message sent again from the delivery log carries the same id as the first time.

Keeping in sync

A SaaS app

  1. 1.Show your plans from GET /products, or hard-code them against the plan ids.
  2. 2.On “Subscribe”, POST /checkout-links with ref = your user id and success_url = your welcome page.
  3. 3.On any subscription.* event, read GET /subscriptions/{ref} and store status and current_period_end against the user.
  4. 4.On login, check what you stored — or call GET /subscriptions/{ref} and cache it for a minute.
  5. 5.Your “Cancel” button calls POST /subscriptions/{id}/cancel.

An online store

  1. 1.Import GET /products into your catalog, keyed by id. On product.updated, re-read GET /products/{id}.
  2. 2.On “Buy”, POST /checkout-links with ref = your order or cart id and success_url = your thank-you page.
  3. 3.On order.paid, read GET /orders/{ref}, mark your order paid and deliver.
  4. 4.On order.refunded, read it again and withdraw access if refunded_cents covers the order.
  5. 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.

StatusCodeMeaning
400invalid_requestA field or parameter is wrong; the message says which.
400invalid_jsonThe body is not a JSON object.
400invalid_referenceThe ref is empty or longer than 200 characters.
401unauthorizedThe key is missing, wrong or revoked.
403plan_requiredThe key is real, but the workspace's plan does not include the Developer API. The key is kept and works again after an upgrade.
403read_only_keyThe key can only read. Make a key with full access in Developers to create checkout links or cancel subscriptions.
404not_foundNo such product, plan or subscription in your workspace.
413payload_too_largeThe body is over 16 KB.
415unsupported_media_typeSend Content-Type: application/json.
503unavailableOur side. Retry with backoff.

Limits

PlansThe API and webhooks are on the Enterprise plan.
API keys20 active per workspace.
Webhooks5 per workspace, https only.
Checkout linksLast 24 hours.
ListsUp to 100 per page.
refUp to 200 characters.
Idempotency-KeyUp to 255 characters.
Request bodyUp to 16 KB.

Questions? Ask us from Support inside Pexally Flow.