Developer API / Documentation
PatientPayments API v1
Create payment links, track them to completion, and receive signed webhooks. Everything on this page is generated from or verified against the live OpenAPI document at https://api.patientpayments.com/api/v1/openapi.json.
Overview
The API is a conventional JSON-over-HTTPS REST API. All requests go to a single base URL; the version lives in the path and breaking changes will only ever ship under a new version.
https://api.patientpayments.com/api/v1
- Request and response bodies are JSON with
camelCaseproperty names. Unknown request properties are rejected withunknown_parameter, so typos never silently do nothing. - Timestamps are ISO 8601 in UTC (
2026-10-15T17:42:10Z). Money is a decimal number of dollars (185.00), never cents. - Every id is opaque and prefixed:
plink_payment links,loc_locations,we_webhook endpoints,evt_events. Do not parse them. - Lists are paged with
page(1-based) andpageSize(default 50, max 100) and return{ object: "list", data, page, pageSize, totalCount, hasMore }. - A key sees exactly one practice: the one that issued it. There is no cross-practice access of any kind.
Authentication
A practice administrator creates keys in the PatientPayments™ app under API & Webhooks. Test keys can be revealed and copied again there at any time; live keys are shown once, so store them in a secrets manager. Send the key as a bearer token on every request.
Authorization: Bearer pp_live_3fA9kQ…
Permissions (scopes)
payment_links:read | List and read payment links and locations |
payment_links:write | Create, update, cancel, and (test mode) simulate |
webhooks:manage | Manage endpoints and inspect deliveries |
A key without the needed scope gets 403 insufficient_scope. Reading locations needs no scope.
Location pinning
Multi-location practices can pin a key to one office. A pinned key only sees that office's links and endpoints,
and locationId becomes optional on create (if you do send it, it must match). An unpinned key on a
multi-location practice must send locationId; on a single-location practice it may omit it.
A missing or invalid key returns 401 invalid_api_key. A valid key for a practice whose API access has been
switched off returns 403 api_access_disabled. Repeated failed authentication from one address is throttled.
Test mode
Keys come in two flavours. pp_test_ keys behave exactly like live keys against the same base URL, with one
difference: nothing they create can move money. A test link's payment page shows a test banner and no pay button, no
processor is involved, and nothing appears in the office's transaction history or reports.
Test and live are fully separate worlds. A test key only ever sees test links and test endpoints; a live key never sees
test data. Webhook endpoints are created in one mode and only receive that mode's events, and every event payload carries
"test": true|false so a shared receiver can tell them apart.
To drive a test link through its lifecycle, call POST /payment-links/{id}/simulate with an outcome.
Each outcome mirrors what the real processor does and publishes the same webhooks it would:
| outcome | What happens | Webhooks |
|---|---|---|
paid | Card payment succeeds. Link becomes paid with a succeeded card transaction. | payment_link.paid |
processing | Bank (ACH) debit submitted. Link becomes paid; transaction is pending. | payment_link.processing |
processing_then_paid | Bank debit submitted and settles. Both events, back to back. | payment_link.processing, payment_link.paid |
failed | Bank debit returned. Link stays paid; transaction is failed with a failureReason. | payment_link.processing, payment_link.failed |
expired | Link passes its expiry unpaid. Status becomes expired. | payment_link.expired |
Simulate on a live link returns 403 test_mode_only. Simulated events have data.context.triggeredBy = simulated.
Quickstart
Three calls: create a link, hand the url to the patient, and register a webhook so you hear when it's paid.
The samples below use a test key; swap in a live key when the office is ready.
1. Create a payment link
curl https://api.patientpayments.com/api/v1/payment-links \
-H "Authorization: Bearer $PP_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 185.00,
"description": "Crown - tooth #14",
"externalReference": "INV-2026-00123",
"patient": {
"firstName": "Jordan",
"lastName": "Rivera",
"phone": "+12085551234",
"email": "jordan.rivera@example.com",
"externalId": "PMS-48213"
},
"metadata": { "ledgerAccount": "4410" }
}'
using System.Net.Http.Headers;
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.patientpayments.com/") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("PP_API_KEY"));
var request = new HttpRequestMessage(HttpMethod.Post, "api/v1/payment-links")
{
Content = JsonContent.Create(new
{
amount = 185.00m,
description = "Crown - tooth #14",
externalReference = "INV-2026-00123",
patient = new
{
firstName = "Jordan",
lastName = "Rivera",
phone = "+12085551234",
email = "jordan.rivera@example.com",
externalId = "PMS-48213"
},
metadata = new Dictionary<string, string> { ["ledgerAccount"] = "4410" }
})
};
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
using var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadFromJsonAsync<ApiError>();
throw new InvalidOperationException($"{error!.Error.Code}: {error.Error.Message}");
}
var link = await response.Content.ReadFromJsonAsync<PaymentLink>();
Console.WriteLine($"{link!.Id} → {link.Url}");
record ApiError(ApiErrorDetail Error);
record ApiErrorDetail(string Code, string Message, string? Param, string? RequestId);
record PaymentLink(string Id, string Url, string Status, decimal Amount, DateTime ExpiresAt);
import { randomUUID } from "node:crypto";
const res = await fetch("https://api.patientpayments.com/api/v1/payment-links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PP_API_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 185.0,
description: "Crown - tooth #14",
externalReference: "INV-2026-00123",
patient: {
firstName: "Jordan",
lastName: "Rivera",
phone: "+12085551234",
email: "jordan.rivera@example.com",
externalId: "PMS-48213",
},
metadata: { ledgerAccount: "4410" },
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const link = await res.json();
console.log(link.id, link.url);
The response is the full payment_link resource. Store id against your invoice and give the patient
url however you like: on a statement, in your portal, or in your own text message.
{
"id": "plink_3f2a9c1d4e5b4f6a8d9c5e3f2a1b0c9d",
"object": "payment_link",
"url": "https://secure.patientpayments.com/pay/K7M2P9QX",
"status": "active",
"test": true,
"amount": 185.00,
"currency": "usd",
"description": "Crown - tooth #14",
"externalReference": "INV-2026-00123",
"metadata": { "ledgerAccount": "4410" },
"patient": { "firstName": "Jordan", "lastName": "Rivera", "phone": "+12085551234", "email": "jordan.rivera@example.com", "externalId": "PMS-48213" },
"location": { "id": "loc_7b1e2c7a4a0b4f6a8d9c5e3f2a1b0c9d", "name": "Downtown Dental" },
"source": "api",
"expiresAt": "2026-11-13T18:03:55Z",
"paidAt": null,
"createdAt": "2026-10-14T18:03:55Z",
"transaction": null,
"refunds": []
}
2. Register a webhook endpoint
Do this once per environment (it is also available in the app, no code required). The response includes the signing
secret exactly once — store it next to your API key.
curl https://api.patientpayments.com/api/v1/webhook-endpoints \
-H "Authorization: Bearer $PP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/patientpayments/webhook",
"enabledEvents": ["payment_link.paid", "payment_link.failed", "payment_link.refunded"],
"description": "Ledger sync"
}'
→ 201 Created
{
"id": "we_9d8c7b6a5f4e4d3c2b1a0f9e8d7c6b5a",
"object": "webhook_endpoint",
"url": "https://api.example.com/patientpayments/webhook",
"enabledEvents": ["payment_link.paid", "payment_link.failed", "payment_link.refunded"],
"status": "enabled",
"test": true,
"secret": "whsec_Yk3c9w2M…",
"createdAt": "2026-10-14T18:05:12Z"
}
3. Simulate a payment and watch the webhook arrive
curl https://api.patientpayments.com/api/v1/payment-links/plink_3f2a9c1d4e5b4f6a8d9c5e3f2a1b0c9d/simulate \
-H "Authorization: Bearer $PP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "outcome": "paid" }'
Within about fifteen seconds your endpoint receives a signed payment_link.paid event. See
Verifying signatures for the receiver side.
Idempotency
POST /payment-links requires an Idempotency-Key header: any unique string up to 200 characters
(a UUID is ideal). If a request times out or your process crashes, retry with the same key and body and you get the
original response back — with an Idempotency-Replayed: true header — instead of a second link. Keys are
remembered for 24 hours.
| Situation | Result |
|---|---|
| Same key, same body | Original status and body replayed; Idempotency-Replayed: true |
| Same key, different body | 422 idempotency_key_reused — pick a new key |
| Same key while the first request is still running | 409 idempotency_key_in_flight — retry shortly |
| Header missing | 400 idempotency_key_required |
First request failed with 4xx | Nothing is stored — fix the request and retry with the same key |
Keys are scoped to the API key that sent them. Cancel and simulate are naturally idempotent and do not need the header.
Errors
Every non-2xx response has the same envelope. Branch on code; message is for humans and may be reworded.
Quote requestId when you contact support.
{
"error": {
"code": "validation_error",
"message": "amount must be between 0.01 and 999999.99",
"param": "amount",
"requestId": "0HN7G3K2Q9V1A:00000003"
}
}
| HTTP | code | Meaning |
|---|---|---|
| 400 | validation_error | A field is missing or out of range; param says which |
| 400 | unknown_parameter | The body contains a property the API does not know |
| 400 | idempotency_key_required | Create was called without an Idempotency-Key |
| 401 | invalid_api_key | Missing, malformed, revoked, or expired key |
| 403 | api_access_disabled | The practice's API access is switched off |
| 403 | insufficient_scope | The key lacks the permission for this action |
| 403 | test_mode_only | Simulate was called with a live key |
| 404 | not_found | No such resource in this practice and mode (ids from other practices also 404) |
| 409 | link_not_active | Update or cancel on a link that is already paid, expired, or cancelled |
| 409 | payments_not_enabled | The practice has not finished payment onboarding; live links cannot be created yet |
| 409 | no_active_location | The practice has no active location to attach the link to |
| 409 | limit_reached | Already at 10 webhook endpoints for this mode |
| 409 | endpoint_disabled | Test, replay, or redeliver on a disabled endpoint |
| 409 | idempotency_key_in_flight | An identical request is still being processed |
| 422 | idempotency_key_reused | The key was used with a different body |
| 429 | rate_limited | Per-key or per-IP limit hit; honour Retry-After |
| 429 | daily_limit_reached | The key's daily link-creation cap is used up; resets at 00:00 UTC |
| 429 | replay_cooldown | Replay was requested for this endpoint less than 10 minutes ago |
| 503 | webhooks_unavailable | Webhook signing is temporarily unavailable; retry later |
Rate limits
- 120 requests per minute per key (sliding window). Higher limits are available on request.
- 600 requests per minute per IP address across all keys.
- 5,000 payment links per key per UTC day.
- Request bodies are limited to 64 KB. Metadata is limited to 20 keys and 2 KB.
A 429 always carries a Retry-After header in seconds. Back off exponentially on 429
and 5xx; never retry 4xx other than 409 idempotency_key_in_flight.
Locations
A practice has one or more locations (offices). Every payment link belongs to one, which decides the receipt branding and
which staff see it. GET /locations lists the active locations the key can see; pinned keys see exactly one.
GET https://api.patientpayments.com/api/v1/locations
{
"object": "list",
"data": [
{ "id": "loc_7b1e2c7a4a0b4f6a8d9c5e3f2a1b0c9d", "object": "location", "name": "Downtown Dental", "city": "Sandpoint", "state": "ID", "timeZone": "America/Los_Angeles" },
{ "id": "loc_2c3d4e5f6a7b4c8d9e0f1a2b3c4d5e6f", "object": "location", "name": "Ponderay Family Dental", "city": "Ponderay", "state": "ID", "timeZone": "America/Los_Angeles" }
],
"page": 1, "pageSize": 50, "totalCount": 2, "hasMore": false
}
Cache the list; it changes only when the practice adds or closes an office. If a location id you hold stops resolving, refresh the list.
Payment links
A payment link is a hosted page where one patient pays one amount by card or US bank account. Links expire (30 days by
default, up to 365) and can be updated or cancelled while active.
Lifecycle
| status | Meaning | transaction.status |
|---|---|---|
active | Waiting for payment. Can be updated or cancelled. | null |
paid | A payment was submitted. For cards this means the money is captured. For bank debits the money is in flight until it settles. | pending → succeeded, or failed if the bank returns it; refunded after a full refund |
expired | Reached expiresAt without payment. | null |
cancelled | Cancelled via the API or by office staff. | null |
paid as soon as the patient
submits it, because the page can no longer be paid again. If the bank later returns the debit, the link stays paid but
transaction.status becomes failed and a payment_link.failed event fires. Treat the balance as
owed again and issue a new link. Post to your ledger on payment_link.paid, not on payment_link.processing.
Matching patients
You never send a PatientPayments™ patient id. For live links the patient is matched inside the practice first by
patient.externalId (your chart or account number), then by mobile number; if neither matches, a patient record
is created. Always send externalId when you have one so the office's history stays tidy. Test links never
touch real patient records.
Updating and cancelling
PATCH /payment-links/{id} accepts amount, description, patient.phone,
patient.email, externalReference, metadata (whole-object replace; null clears), and
expiresAt. Omitted fields are untouched. POST /payment-links/{id}/cancel is idempotent; cancelling an already
cancelled link returns it unchanged. Both return 409 link_not_active once the link is paid or expired.
Listing
GET /payment-links filters by status, externalReference (exact), locationId,
createdAfter, createdBefore, and paidAfter. It is intended for reconciliation sweeps
(status=paid&paidAfter=… returns everything paid since your last sweep); for real-time state use webhooks.
Webhooks
When a payment link changes state we POST a JSON event to every endpoint you have registered for that mode and event
type. Deliveries are signed, retried for up to three days, and kept for inspection, redelivery, and replay. Up to 10 live and
10 test endpoints per practice.
Registering an endpoint
Endpoints can be created in the app or via POST /webhook-endpoints. The URL must be public HTTPS; private, loopback,
link-local, and bare-hostname addresses are rejected at creation and again at send time. Leave enabledEvents empty
to receive every event type, including ones added in the future. Endpoints can optionally be restricted to one location.
- Respond with any
2xxwithin 10 seconds. Do the real work after you have acknowledged — queue the event and return. - We ignore the response body; return an empty
200or204. - Redirects are not followed. Return the final URL.
- A
4xxother than429counts as a permanent failure for that attempt but is still retried on the normal schedule.
Event payload
Every event has the same shape. data.paymentLink is the full resource as it was at the moment of the event, so a
handler rarely needs a follow-up call. data.context carries extra detail for some types.
POST /patientpayments/webhook HTTP/1.1
Content-Type: application/json
User-Agent: PatientPayments-Webhooks/1.0
PatientPayments-Event-Id: evt_1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6
PatientPayments-Event-Type: payment_link.paid
PatientPayments-Signature: t=1760550130,v1=5f1d3b9c…e2a7
{
"id": "evt_1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6",
"object": "event",
"type": "payment_link.paid",
"test": false,
"createdAt": "2026-10-15T17:42:10Z",
"data": {
"paymentLink": {
"id": "plink_3f2a9c1d4e5b4f6a8d9c5e3f2a1b0c9d",
"object": "payment_link",
"status": "paid",
"amount": 185.00,
"externalReference": "INV-2026-00123",
"metadata": { "ledgerAccount": "4410" },
"paidAt": "2026-10-15T17:42:10Z",
"transaction": {
"status": "succeeded",
"amount": 185.00,
"fee": 5.67,
"net": 179.33,
"paymentMethodType": "card",
"paymentMethodBrand": "visa",
"paymentMethodLast4": "4242",
"receiptUrl": "https://pay.stripe.com/receipts/…",
"processedAt": "2026-10-15T17:42:10Z"
},
"refunds": []
},
"context": null
}
}
Verifying signatures
Every delivery carries a PatientPayments-Signature header. Verify it before trusting anything in the body.
PatientPayments-Signature: t=1760550130,v1=5f1d3b9c…e2a7
- Read the raw request body as bytes/string. Do not parse and re-serialise it — the signature covers the exact bytes we sent.
- Split the header on commas.
tis a Unix timestamp in seconds; there are one or morev1values. - Reject if
tis more than 5 minutes from now (protects against replaying captured requests). - Compute
HMAC-SHA256(secret, "{t}.{raw body}")and hex-encode it in lower case. - Compare it with each
v1using a constant-time comparison. Accept if any matches.
using System.Security.Cryptography;
using System.Text;
app.MapPost("/patientpayments/webhook", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var header = request.Headers["PatientPayments-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("PP_WEBHOOK_SECRET")!;
if (!WebhookSignature.Verify(header, body, secret))
return Results.Unauthorized();
var eventId = request.Headers["PatientPayments-Event-Id"].ToString();
// 1. INSERT eventId into a processed-events table; if it already exists, return 200 and stop.
// 2. Enqueue the body for processing and return immediately.
await queue.EnqueueAsync(eventId, body);
return Results.Ok();
});
public static class WebhookSignature
{
public static bool Verify(string header, string body, string secret, int toleranceSeconds = 300)
{
long? timestamp = null;
var signatures = new List<string>();
foreach (var part in header.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var eq = part.IndexOf('=');
if (eq <= 0) continue;
var key = part[..eq];
var value = part[(eq + 1)..];
if (key == "t" && long.TryParse(value, out var t)) timestamp = t;
else if (key == "v1") signatures.Add(value.ToLowerInvariant());
}
if (timestamp is null || signatures.Count == 0) return false;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - timestamp.Value) > toleranceSeconds) return false;
var payload = Encoding.UTF8.GetBytes($"{timestamp}.{body}");
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), payload);
var expected = Encoding.UTF8.GetBytes(Convert.ToHexString(hash).ToLowerInvariant());
foreach (var sig in signatures)
{
var provided = Encoding.UTF8.GetBytes(sig);
if (provided.Length == expected.Length && CryptographicOperations.FixedTimeEquals(provided, expected))
return true;
}
return false;
}
}
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// Keep the raw body: verification must run on the exact bytes we sent.
app.post("/patientpayments/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const body = req.body.toString("utf8");
const header = req.get("PatientPayments-Signature") ?? "";
if (!verify(header, body, process.env.PP_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const eventId = req.get("PatientPayments-Event-Id");
// 1. Insert eventId into a processed-events table; if it already exists, respond 200 and stop.
// 2. Queue the body for processing and respond immediately.
await queue.enqueue(eventId, body);
res.status(200).end();
});
function verify(header, body, secret, toleranceSeconds = 300) {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") timestamp = Number(value);
else if (key === "v1") signatures.push(value.toLowerCase());
}
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
const expectedBuf = Buffer.from(expected);
return signatures.some((sig) => {
const buf = Buffer.from(sig);
return buf.length === expectedBuf.length && timingSafeEqual(buf, expectedBuf);
});
}
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["PP_WEBHOOK_SECRET"].encode()
@app.post("/patientpayments/webhook")
def webhook():
body = request.get_data() # raw bytes — never re-serialise the JSON before verifying
header = request.headers.get("PatientPayments-Signature", "")
if not verify(header, body, SECRET):
abort(401)
event_id = request.headers.get("PatientPayments-Event-Id")
# 1. Insert event_id into a processed-events table; if it already exists, return 200 and stop.
# 2. Queue the body for processing and return immediately.
queue.enqueue(event_id, body)
return "", 200
def verify(header: str, body: bytes, secret: bytes, tolerance_seconds: int = 300) -> bool:
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t":
timestamp = int(value)
elif key == "v1":
signatures.append(value.lower())
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False
expected = hmac.new(secret, f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
Rotating the secret. Rotate in the app or with POST /webhook-endpoints/{id}/rotate-secret. For the next
24 hours every delivery carries two v1 values — new secret first, old second — so switch your receiver whenever
convenient inside that window. The verifiers above already accept either.
Event types
| type | When | data.context |
|---|---|---|
payment_link.processing | A bank (ACH) debit was submitted and is settling (3–5 business days). Link is paid; transaction.status is pending. | — |
payment_link.paid | Money captured. Immediate for cards; after processing for bank debits. Post on this event. | — |
payment_link.failed | A bank debit was returned after submission. Link stays paid; transaction.status is failed. The balance is owed again. | failureReason |
payment_link.refunded | The office refunded some or all of the payment, or a bank reversal occurred. refunds[] on the link lists every refund so far. | refundAmount |
payment_link.disputed | The patient's bank opened a dispute (chargeback). The office responds in PatientPayments; your ledger may want a hold. | disputeReason, disputeAmount, evidenceDueBy |
payment_link.dispute_closed | The dispute was resolved. | disputeOutcome: won or lost |
payment_link.expired | The link passed expiresAt unpaid. | triggeredBy: system |
payment_link.cancelled | The link was cancelled. | triggeredBy: api or staff |
ping | Only when you send a test from the app or POST /webhook-endpoints/{id}/test. data is empty. | — |
Events for links created by office staff or from the practice website are delivered too, with source set to staff or website. Filter on source or externalReference if you only care about your own links.
Retries, ordering, and duplicates
A delivery is successful when your endpoint returns 2xx within 10 seconds. Anything else — a timeout, a connection
error, or a non-2xx status — schedules a retry. Up to 8 attempts are made over roughly three days:
attempt 2: +30 s attempt 3: +2 min attempt 4: +10 min attempt 5: +1 h
attempt 6: +6 h attempt 7: +24 h attempt 8: +48 h (each ±10 % jitter)
- Duplicates happen. Retries, redelivery, and replay all reuse the same
evt_id. Store processed ids and treat a repeat as a no-op. The id is in both the body and thePatientPayments-Event-Idheader. - Order is not guaranteed. Each delivery retries independently, so
payment_link.paidcan arrive before a retriedpayment_link.processing. UsecreatedAtand the link'sstatus/transaction.statusin the payload rather than assuming sequence. - One in flight per endpoint. We never send two deliveries to the same endpoint concurrently. If an endpoint is timing out, further deliveries to it wait for the next pass instead of piling on.
- Auto-disable. After 20 consecutive events exhaust all attempts, the endpoint is disabled and the practice admin is emailed. Re-enable it in the app or with
PATCH { "enabled": true }, then replay what you missed.
Replay and redelivery
POST /webhook-deliveries/{id}/redeliver queues one specific event again. POST /webhook-endpoints/{id}/replay
with { "since": "2026-10-10T00:00:00Z" } re-queues every event for that endpoint that was not delivered, and
creates events for links that were paid, cancelled, or expired in the window while the endpoint did not exist or was
disabled. The window is at most 30 days, replays are capped at 1,000 events, and an endpoint can be replayed once every 10 minutes.
Replayed events keep their original ids, so an idempotent receiver handles them for free.
GET /webhook-deliveries (filter by endpointId, paymentLinkId, status, type,
since) and GET /webhook-deliveries/{id} (includes the exact payload) show what was sent, when, and what your
endpoint answered. Delivered events are kept 30 days; failed and pending ones 90 days.
Polling fallback. Webhooks are a notification, not the system of record. If you need certainty — a nightly
reconciliation, or a receiver that was down for longer than the retry window — call
GET /payment-links?status=paid&paidAfter=2026-10-13T00:00:00Z and compare against your ledger. The same
transaction and refunds fields that appear in webhook payloads are on every link resource.
Testing your receiver
- Register a test-mode endpoint pointing at a public URL (a tunnel such as ngrok or Cloudflare Tunnel is fine during development).
- Click Test in the app or call
POST /webhook-endpoints/{id}/test. A signedpingarrives within about 15 seconds; check it verifies. - Create a test link and
POST /simulateeach outcome. You will receive the exact payloads live traffic produces, with"test": true. - Return a
500on purpose once and watch the delivery move toretryingwith anextAttemptAtinGET /webhook-deliveries. - When you go live, register a live-mode endpoint with the live key (or in the app). The test endpoint can stay; it never receives live events.
Go-live checklist
- Swap the
pp_test_key for app_live_key with only the scopes you use. Keep it in a secrets store; live keys are shown once. - Register the live endpoint (HTTPS, public, responds
2xxin under 10 seconds) and store its secret separately from the test secret. - Confirm your receiver verifies the signature, de-duplicates on the
evt_id, and processes asynchronously. - Create one real link for a small amount, pay it with a real card, and confirm the first
payment_link.paidposts to your ledger. - Decide who gets the auto-disable email at the practice, and schedule the polling reconciliation above so a disabled endpoint cannot leave payments unposted.
API reference
Rendered from the live OpenAPI document. Import /developers/openapi.json into Postman, Insomnia, or your client generator of choice, or open the reference full screen.