Skip to main content

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 camelCase property names. Unknown request properties are rejected with unknown_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) and pageSize (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:readList and read payment links and locations
payment_links:writeCreate, update, cancel, and (test mode) simulate
webhooks:manageManage 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.

Keep keys server-side. Never ship a key in a browser, mobile app, or public repository. If one leaks, revoke it in the app — every request with it fails immediately — and create a replacement. Keys can also be given an expiry.

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:

outcomeWhat happensWebhooks
paidCard payment succeeds. Link becomes paid with a succeeded card transaction.payment_link.paid
processingBank (ACH) debit submitted. Link becomes paid; transaction is pending.payment_link.processing
processing_then_paidBank debit submitted and settles. Both events, back to back.payment_link.processing, payment_link.paid
failedBank debit returned. Link stays paid; transaction is failed with a failureReason.payment_link.processing, payment_link.failed
expiredLink 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" }
  }'

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.

SituationResult
Same key, same bodyOriginal status and body replayed; Idempotency-Replayed: true
Same key, different body422 idempotency_key_reused — pick a new key
Same key while the first request is still running409 idempotency_key_in_flight — retry shortly
Header missing400 idempotency_key_required
First request failed with 4xxNothing 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"
  }
}
HTTPcodeMeaning
400validation_errorA field is missing or out of range; param says which
400unknown_parameterThe body contains a property the API does not know
400idempotency_key_requiredCreate was called without an Idempotency-Key
401invalid_api_keyMissing, malformed, revoked, or expired key
403api_access_disabledThe practice's API access is switched off
403insufficient_scopeThe key lacks the permission for this action
403test_mode_onlySimulate was called with a live key
404not_foundNo such resource in this practice and mode (ids from other practices also 404)
409link_not_activeUpdate or cancel on a link that is already paid, expired, or cancelled
409payments_not_enabledThe practice has not finished payment onboarding; live links cannot be created yet
409no_active_locationThe practice has no active location to attach the link to
409limit_reachedAlready at 10 webhook endpoints for this mode
409endpoint_disabledTest, replay, or redeliver on a disabled endpoint
409idempotency_key_in_flightAn identical request is still being processed
422idempotency_key_reusedThe key was used with a different body
429rate_limitedPer-key or per-IP limit hit; honour Retry-After
429daily_limit_reachedThe key's daily link-creation cap is used up; resets at 00:00 UTC
429replay_cooldownReplay was requested for this endpoint less than 10 minutes ago
503webhooks_unavailableWebhook 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.

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 2xx within 10 seconds. Do the real work after you have acknowledged — queue the event and return.
  • We ignore the response body; return an empty 200 or 204.
  • Redirects are not followed. Return the final URL.
  • A 4xx other than 429 counts 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
  1. Read the raw request body as bytes/string. Do not parse and re-serialise it — the signature covers the exact bytes we sent.
  2. Split the header on commas. t is a Unix timestamp in seconds; there are one or more v1 values.
  3. Reject if t is more than 5 minutes from now (protects against replaying captured requests).
  4. Compute HMAC-SHA256(secret, "{t}.{raw body}") and hex-encode it in lower case.
  5. Compare it with each v1 using 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;
    }
}

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

typeWhendata.context
payment_link.processingA bank (ACH) debit was submitted and is settling (3–5 business days). Link is paid; transaction.status is pending.—
payment_link.paidMoney captured. Immediate for cards; after processing for bank debits. Post on this event.—
payment_link.failedA bank debit was returned after submission. Link stays paid; transaction.status is failed. The balance is owed again.failureReason
payment_link.refundedThe 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.disputedThe patient's bank opened a dispute (chargeback). The office responds in PatientPayments; your ledger may want a hold.disputeReason, disputeAmount, evidenceDueBy
payment_link.dispute_closedThe dispute was resolved.disputeOutcome: won or lost
payment_link.expiredThe link passed expiresAt unpaid.triggeredBy: system
payment_link.cancelledThe link was cancelled.triggeredBy: api or staff
pingOnly 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 the PatientPayments-Event-Id header.
  • Order is not guaranteed. Each delivery retries independently, so payment_link.paid can arrive before a retried payment_link.processing. Use createdAt and the link's status/transaction.status in 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

  1. Register a test-mode endpoint pointing at a public URL (a tunnel such as ngrok or Cloudflare Tunnel is fine during development).
  2. Click Test in the app or call POST /webhook-endpoints/{id}/test. A signed ping arrives within about 15 seconds; check it verifies.
  3. Create a test link and POST /simulate each outcome. You will receive the exact payloads live traffic produces, with "test": true.
  4. Return a 500 on purpose once and watch the delivery move to retrying with a nextAttemptAt in GET /webhook-deliveries.
  5. 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 a pp_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 2xx in 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.paid posts 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.