Creating Purchases
Create a payment and get a checkout URL for the customer.
Endpoint
POST /api/purchases/create/
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
application | string | Yes | Application identifier (hashid) shown in the PWP admin |
sku | string | Yes | Product SKU |
email_address | string | One | Customer email address |
ref_id | string | One | Customer Reference ID |
major_currency | string | Yes | Payment currency ISO code (e.g. "USD") |
minor_currency | string | Yes | Product pricing currency ISO code (e.g. "USD") |
subscription_type | string | No | "MONTH", "YEAR", or "ONE_TIME" — selects a matching pricing tier when a product has multiple prices in the requested currency |
success_url | string | No | URL to redirect after successful payment. Must start with https:// |
cancel_url | string | No | URL to redirect if payment is cancelled. Must start with https:// |
coupon | string | No | Coupon code — see Coupons |
payment_processor | string | No | Gateway name (e.g. "PayPal", "Authorize.Net"; case/punctuation-insensitive). When present, the gateway order is engaged immediately and its checkout URL is returned as location. When omitted, the purchase is created bare and location is the invoice URL where the customer picks a processor. |
Provide exactly one of email_address or ref_id. Include application in
every request. This identifier is safe to send as the tenant selector;
requests still must be signed with that application's API_SECRET.
Signing Requests
Code
purchases/create and purchases/cancel both require a signed request, verified
against your API_SECRET.
| Header | Value |
|---|---|
X-PWP-Signature | HMAC-SHA512 hex digest of the raw request body |
// Bun
import { createHmac } from 'crypto';
const timestamp = Math.floor(Date.now() / 1000);
const body = `application=${APPLICATION_REF}&sku=${sku}&ref_id=${uid}×tamp=${timestamp}`;
const sig = createHmac('sha512', API_SECRET).update(body).digest('hex');
await fetch('https://pay.with.pink/api/purchases/create/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-PWP-Signature': sig,
},
body,
});
Details
The body must include a timestamp (unix seconds) within five minutes of server
time and use application/x-www-form-urlencoded. JSON requests are rejected
before signature verification. PWP signs the exact raw body bytes, so serialize
the body once, sign that string, and send it without re-encoding. The signature
uses your application's API_SECRET.
PWP also uses X-PWP-Signature for outbound webhooks, but those use the separate
IPN_SECRET and are signed by PWP. Do not use the webhook secret to sign API
requests.
API_SECRET isn't generated automatically for an application that existed
before this signing requirement was added. Ask PWP to generate one from the
admin if requests fail with API authentication not configured (500).
Response
{
"id": "abc123",
"invoice": "https://pay.with.pink/purchases/abc123/",
"location": "https://www.paypal.com/checkoutnow?token=...",
"status": "PENDING"
}
| Field | Type | Description |
|---|---|---|
id | string | Purchase identifier |
invoice | string | PWP invoice URL (always present, universal fallback) |
location | string | null | Gateway checkout URL when payment_processor was sent; otherwise the invoice URL for a pending purchase, or null when the purchase is already paid (for example, a $0 product) |
status | string | "PENDING" or "PAID" (for $0 products) |
Contract change (2026-07):
locationused to always carry the PayPal checkout URL for USD purchases. It now carries the requested gateway URL, or the invoice URL when no processor was requested and payment is still pending. Useinvoiceas the stable customer-facing URL;locationisnullfor purchases that are already paid, such as $0 products.
Status Lifecycle
- PENDING — awaiting customer payment
- PAID — payment captured; recurring purchases create or extend a subscription, while one-time purchases complete without one
- CANCELED — customer cancelled at checkout
- EXPIRED — payment window expired
Free Products ($0)
Products priced at $0 are resolved immediately without a payment gateway. The response will have status: "PAID" and location: null. A one-time $0 product is still a completed purchase and emits purchase.completed to the configured IPN URL.
When a product has multiple prices in the same currency, pass the matching
subscription_type (MONTH, YEAR, or ONE_TIME) to select the intended
price. In particular, use ONE_TIME when choosing a one-time price alongside
recurring prices. When this field is omitted, PWP keeps the existing first
matching price behavior for compatibility.
A tenant can turn free subscriptions off (application.free_subscriptions = 0,
admin-only toggle — see .taskflow/notes/free-subscriptions.md). Once off,
purchases/create refuses every new $0 purchase for that tenant with a 403
(see Errors below); existing $0 subscriptions are unaffected until their
current term ends (they wind down via subscription.expired, not
retroactively canceled — see Webhooks).
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | Missing application selector | The application field is missing |
| 400 | {"error": "Content-Type must be application/x-www-form-urlencoded"} | Body sent as JSON or another content type |
| 400 | {"error": "Invalid subscription_type. Must be MONTH, YEAR, or ONE_TIME."} | Bad subscription_type |
| 400 | {"error": "Unknown payment_processor: ..."} | payment_processor does not match a registered gateway |
| 400 | {"error": "..."} | Currency not supported for product |
| 401 | {"error": "Invalid signature"} | X-PWP-Signature does not match — check key order and raw-body HMAC |
| 401 | {"error": "Request expired"} | timestamp missing, zero, or outside ±5 min window |
| 403 | {"error": "Forbidden: Free subscriptions are disabled for application \"...\"."} | Product resolves to $0 (list price) and this tenant has free_subscriptions off |
| 404 | {"error": "..."} | Application or product not found |
Cancelling Purchases
POST /api/purchases/cancel/ cancels every ACTIVE subscription for the given
account and product. It requires the same Signing Requests
scheme as purchases/create.
| Field | Type | Required | Description |
|---|---|---|---|
application | string | Yes | Application identifier (hashid) shown in the PWP admin |
sku | string | Yes | Product SKU |
email_address | string | One | Customer email address |
ref_id | string | One | Customer Reference ID |
timestamp | integer | Yes | Current unix timestamp (seconds), for signature replay protection |
Include application in every request. The account must already exist — this
endpoint never creates one. An unknown account or a sku with no matching
product both return the same uniform error, to avoid leaking which accounts
exist.
{
"subscriptions": 1
}
| Field | Type | Description |
|---|---|---|
subscriptions | integer | Count of subscriptions cancelled |