← Back to docs

Creating Purchases

Create a payment and get a checkout URL for the customer.

Endpoint

POST /api/purchases/create/

Request Body

FieldTypeRequiredDescription
applicationstringYesApplication identifier (hashid) shown in the PWP admin
skustringYesProduct SKU
email_addressstringOneCustomer email address
ref_idstringOneCustomer Reference ID
major_currencystringYesPayment currency ISO code (e.g. "USD")
minor_currencystringYesProduct pricing currency ISO code (e.g. "USD")
subscription_typestringNo"MONTH", "YEAR", or "ONE_TIME" — selects a matching pricing tier when a product has multiple prices in the requested currency
success_urlstringNoURL to redirect after successful payment. Must start with https://
cancel_urlstringNoURL to redirect if payment is cancelled. Must start with https://
couponstringNoCoupon code — see Coupons
payment_processorstringNoGateway 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.

HeaderValue
X-PWP-SignatureHMAC-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}&timestamp=${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"
}
FieldTypeDescription
idstringPurchase identifier
invoicestringPWP invoice URL (always present, universal fallback)
locationstring | nullGateway 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)
statusstring"PENDING" or "PAID" (for $0 products)

Contract change (2026-07): location used 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. Use invoice as the stable customer-facing URL; location is null for purchases that are already paid, such as $0 products.

Status Lifecycle

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

StatusBodyCause
400Missing application selectorThe 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.

FieldTypeRequiredDescription
applicationstringYesApplication identifier (hashid) shown in the PWP admin
skustringYesProduct SKU
email_addressstringOneCustomer email address
ref_idstringOneCustomer Reference ID
timestampintegerYesCurrent 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
}
FieldTypeDescription
subscriptionsintegerCount of subscriptions cancelled