Payment-Links
A payment-link is a stable URL you can hand off to a customer — by email, by chat, by QR code on a printed invoice, or as a pay button on a third-party site — and have them pay against. Each link carries an amount, currency, optional description, optional expiry, and an optional cap on how many payments it will accept before it retires. The Payment-Links API lets you create, read, update, and reconcile these links programmatically.
Payment-links have a status-managed lifecycle. There is no DELETE endpoint — to retire a link, update its status to inactive (it can be reactivated later) or let it expire automatically. This is intentional: the link's history (events, paid count, audit trail) stays preserved and reconcilable, which your dashboards depend on.
Discover the Payment-Links resource using Postman:
TapTree Postman CollectionEndpoints
Update a payment-link
Update fields on an existing payment-link, including the status toggle (activate/deactivate).
List all payment-links
Retrieve a list of payment-links with optional status filter and cursor pagination.
List payments for a payment-link
List the payments customers have made through a specific payment-link.
Payment-link status
Every payment-link starts as active the moment you create it and can transition into inactive (paused, recoverable) or expired (terminal). There is no separate active boolean — the three states do not reduce cleanly to one, so status is the single authoritative field clients should branch on.
| Status | Description | How it's reached | Reversible? |
|---|---|---|---|
active | The link will accept payments. Default after creation. | Default on create; or via POST /v1/payment_links/:id with status: "active" from inactive. | n/a (initial) |
inactive | Paused — the checkout page rejects new payments with HTTP 409. The link object still exists, history preserved, scannable in dashboard. | POST /v1/payment_links/:id with status: "inactive"; or automatically when remaining_payments reaches zero on a link with a non-null payments_limit (auto-inactivate at limit-reached, emitted as transactions.payment_link.auto_inactivated). | Yes — set status: "active" |
expired | Terminal — link is dead. Checkout rejects with 409. Cannot be revived. | Reached automatically when expires_at elapses (cron job runs every minute), or when the checkout-gate detects an elapsed expires_at on first access. | No — terminal |
Auto-inactivate at limit-reached. Once a link's remaining_payments reaches zero (only possible when payments_limit was set at create), the link automatically transitions from active to inactive. The transition is performed in the same database statement as the final transactions.payment.paid event, and a dedicated transactions.payment_link.auto_inactivated webhook fires synchronously alongside it with payload { payment_id, paid_count_at_inactivation, payments_limit, reason: "limit_reached" }. The link can be reactivated later via POST /v1/payment_links/:id with status: "active" if you want to re-open it — but the payments_limit will still gate further attempts until you also raise the cap.
The expired state is terminal by design. Once a link has been formally retired by elapsed expiry, you cannot resurrect it — issue a new link instead. The status field accepts only active or inactive on update. Sending status: "expired" is rejected at the request-validation layer with 400 Bad Request (Fastify schema enum); the SQL layer additionally enforces this as defense-in-depth, raising a 422 Unprocessable Entity with attribute: "status" and detail: "status must be one of active, inactive" in case a caller bypasses schema validation. Attempting status: "active" (or "inactive") against an already-expired link returns 422 Unprocessable Entity with detail: "Status cannot be changed once expired". This keeps audit history clean and prevents accidentally re-activating links whose business context (a specific invoice, reservation, time-window) is gone.
Status-driving events
These domain events are emitted as the link transitions. Subscribe via the webhooks system if you want the dashboard's view of the lifecycle in your own backend.
| Event type | When it fires | Aggregate |
|---|---|---|
transactions.payment_link.created | A new link is created via POST /v1/payment_links | The new payment_link |
transactions.payment_link.properties.changed | Any update via POST /v1/payment_links/:id (including status toggles) | The updated payment_link |
transactions.payment_link.expired | Cron-driven or checkout-gate-driven transition into expired | The expired payment_link |
transactions.payment_link.payment.created | A new payment was created against this link (customer hit checkout) | The link; payload carries the new payment_id |
transactions.payment_link.payment.reused | An existing open payment was reused for the same client-key (idempotent checkout) | The link; payload carries the reused payment_id |
transactions.payment_link.remaining_payments.decremented | Fires after a successful payment is recorded against a limited link, when remaining_payments > 0 before the decrement. The new value is part of the payload. | The link; payload includes payment_id and the new remaining_payments value. |
transactions.payment_link.auto_inactivated | The link's remaining_payments reached zero and the link automatically transitioned to inactive (limit-reached). Fires synchronously with the triggering transactions.payment.paid event, in the same database statement. | The link; payload includes payment_id, paid_count_at_inactivation, payments_limit, and reason: "limit_reached". |
transactions.payment_link.checkout.requested | Checkout page was opened against this link | The link |
transactions.payment_link.checkout.denied | Checkout was opened but rejected. data.reason reflects status-first precedence: expired if status is already expired (or status was active but expires_at just elapsed, in which case it fires alongside the auto-transition to expired); inactive if status is inactive; limit_reached if status is active and remaining_payments is 0. | The link |
In addition to the lifecycle events above, the platform also emits an email.* family — transactions.payment_link.email.sent, email.failed, email.delivered, and email.complained — from the email-send-on-create pipeline. Consumers that subscribe to the wildcard transactions.payment_link.* will receive these. Subscribe selectively if you only care about lifecycle transitions.
The payment-link object
The payment-link object is the durable record of one paying-shortcut. It is created with an amount and currency, optionally constrained by a payment cap and/or an expiry, and is bound to one acceptor for the lifetime of its history.
Core attributes
- Name
id- Type
- string
- Description
Unique identifier for the payment-link, prefixed
pl_, e.g.pl_AncTwHXWLff. The id is opaque, base-58 encoded, and stable for the lifetime of the link.
- Name
object- Type
- string
- Description
The object type. Always
payment_link. Use this as a stable discriminator for typed clients.
- Name
status- Type
- string
- Description
Current status of the payment-link.
Possible values:
activeinactiveexpiredSee the Payment-link status section above for the full transition matrix.
- Name
amount- Type
- object
- Description
The amount each customer pays through this link. Object with a numeric
value(in major units) and thecurrency.
- Name
description- Type
- string
- Description
Free-form short text shown on the checkout page and surfaced in dashboards and webhooks.
nullif not provided on create. Maximum 500 characters.
- Name
internal_reference- Type
- string
- Description
Your own external reference (e.g. order id, invoice number, reservation code).
nullif not provided on create. Up to 255 characters. Mutable via Update — see the Update endpoint for tri-state semantics.
- Name
redirect_url- Type
- string
- Description
Where the customer is redirected after a successful checkout.
nullfalls back to the default TapTree thank-you page. Create-time only — cannot be changed after creation.
- Name
created_at- Type
- datetime
- Description
Timestamp at which the link was created, in ISO 8601 format.
- Name
acceptor_id- Type
- string
- Description
The acceptor (website or merchant location) this link bills against. Inherited from the API key's acceptor binding by default when the key is acceptor-bound; required in the request body for org-scoped keys.
- Name
org_id- Type
- string
- Description
Unique identifier for the organization the link belongs to. Always matches the org of the calling API key.
- Name
links- Type
- object
- Description
Convenience URLs for downstream use.
The API response intentionally exposes only the
checkoutlink. The dashboard URL is internal and not returned to API consumers.
Optional and lifecycle attributes
- Name
payments_limit- Type
- integer
- Optional
- optional
- Description
The cap on how many distinct successful payments the link will accept.
null(default) means unlimited. Each successful payment emits atransactions.payment_link.remaining_payments.decrementedevent; onceremaining_paymentsreaches zero, the link automatically transitions toinactiveand atransactions.payment_link.auto_inactivatedevent is emitted in the same database statement as the finaltransactions.payment.paid. Useful for "exactly one customer", "ten seats", or single-use deposit-style links.
|- Name
remaining_payments- Type
- integer
- Optional
- optional
- Description
The number of payments still accepted, derived as
payments_limit - paid_count. Decremented after each successful payment.nullwhilepayments_limitisnull.
|- Name
paid_count- Type
- integer
- Description
Total successful payments against this link. Never decreases — even refunded payments are counted (the link did serve them). Defaults to
0on create.
- Name
first_paid_at- Type
- datetime
- Optional
- optional
- Description
Timestamp of the first successful payment via this link, in ISO 8601 format.
nulluntil a payment first reachespaidstate.
|- Name
last_paid_at- Type
- datetime
- Optional
- optional
- Description
Timestamp of the most recent successful payment, in ISO 8601 format. Useful for "is this link still seeing traffic?" dashboards.
|- Name
expires_at- Type
- datetime
- Optional
- optional
- Description
Optional ISO 8601 timestamp. After this moment the link transitions to
expiredand rejects further checkouts.nullmeans no expiry.When a link is not
active,expires_atreturns asnullin API responses. Forexpiredlinks, the underlying column is also physically cleared on transition. Forinactivelinks, the originalexpires_atvalue is preserved in storage but masked in the response; if you reactivate the link by settingstatus: "active", the originalexpires_atreappears. The terminal-transition timestamp is preserved inexpired_atregardless.
|- Name
expired_at- Type
- datetime
- Optional
- optional
- Description
ISO 8601 timestamp at which the link actually transitioned to
expired.nulluntil expiry fires.
|
Test-mode and live-mode payment-links live in separate tables and ids are not interchangeable. The mode of a given link is determined by the API key used to create it — there is no separate mode field on the response object; you implicitly know the mode from which key was used to read the link.
Payment-link object
{
"object": "payment_link",
"id": "pl_AncTwHXWLff",
"status": "active",
"amount": {
"value": 12.50,
"currency": "EUR"
},
"description": "Reservierung 4456",
"internal_reference": "order-4456",
"redirect_url": "https://example.com/thank-you?order=4456",
"payments_limit": 1,
"remaining_payments": 1,
"paid_count": 0,
"expires_at": "2026-06-30T23:59:59Z",
"expired_at": null,
"first_paid_at": null,
"last_paid_at": null,
"created_at": "2026-05-23T15:42:11.819Z",
"acceptor_id": "acceptor_ABJZLqBvy7Y",
"org_id": "org_5A8hsNqGGyW",
"links": {
"checkout": {
"href": "https://checkout.taptree.org/link/pl_AncTwHXWLff",
"type": "text/html"
}
}
}
Authentication & scopes
The Payment-Links API is gated by v2 API keys. Three fine-grained scopes control access, one per action:
| Action | Scope token | Endpoints |
|---|---|---|
| Create | payment_link:create | POST /v1/payment_links |
| Read | payment_link:read | GET /v1/payment_links, GET /v1/payment_links/:id, GET /v1/payment_links/:id/payments |
| Update | payment_link:update | POST /v1/payment_links/:id (incl. status toggle) |
payment_link is a distinct resource from payments — granting one scope does NOT imply the other. Configure scopes in the dashboard under Entwickler → API-Schlüssel → Scopes.
The sub-resource GET /v1/payment_links/:id/payments re-uses the parent payment_link:read scope — you do not need payments:read on top. The semantic "show me what came through MY link" is treated as a subset of "read my link".
Acceptor binding
API keys can be org-scoped (no acceptor binding) or acceptor-bound (tied to one acceptor). Payment-links are themselves bound to an acceptor at creation time.
- Acceptor-bound key:
acceptor_iddefaults to the key's bound acceptor on create; it can be overridden to any acceptor in the same organization. Cross-org acceptor references return422 Unprocessable Entity. Read and update access is org-wide — payment-links are organization-scoped resources, not restricted to the API key's bound acceptor. - Org-scoped key (no binding):
acceptor_idis required in the request body on create. Read and update access is org-wide.
Cross-org access is blocked at every endpoint; cross-org probes return 404 Not Found (no existence disclosure). The cross-org check is enforced at the SQL layer too, so even a misrouted request cannot leak data across organization boundaries.
Admin-user gotcha
API keys minted by a user with the global users.role = 'admin' role will be rejected by every payment-link endpoint with 422 and detail: "API key configuration is invalid (creator role)". This is intentional defense-in-depth — the admin role's permission-check shortcut would otherwise bypass the per-org role gates that the API enforces via the key creator. Mint API keys from a non-admin user (e.g. an org-owner or org-admin account).
Create a payment-link
Creates a new payment-link. Returns 201 Created with the full payment-link object on success.
The link starts in active status and is immediately ready to accept payments at links.checkout.href.
Required scope: payment_link:create.
Required attributes
- Name
amount- Type
- object
- Description
The amount each customer pays through this link. Object with a string-encoded
value(input only — see asymmetry note below) and thecurrency.To charge twelve euros and fifty cents, pass:
{ "value": "12.50", "currency": "EUR" }Input/output asymmetry: the API accepts
amount.valueas a string on input (Fastify schema pattern^[0-9]+(\.[0-9]{1,2})?$) but returns it as a JSON number on output (12.50, not"12.50"). If you round-trip a payment-link through your client, normalize the response field before sending it back in another request.
Optional attributes
- Name
description- Type
- string
- Optional
- optional
- Description
Free-form short text shown on the checkout page. Up to 500 characters.
|- Name
acceptor_id- Type
- string
- Optional
- optional
- Description
The acceptor this link bills against. Required for org-scoped API keys. For acceptor-bound API keys, defaults to the key's bound acceptor; can be overridden to any acceptor in the same organization (cross-org acceptor →
422).
|- Name
payments_limit- Type
- integer
- Optional
- optional
- Description
Number of accepted payments before the link is considered "spent". Must be
>= 1if specified. Omit or set tonullfor an unlimited link. Onceremaining_paymentsreaches zero, the link automatically transitions toinactive(atransactions.payment_link.auto_inactivatedwebhook fires alongside the triggeringtransactions.payment.paid), and the checkout-gate denies further attempts withreason: "limit_reached".
|- Name
expires_at- Type
- datetime
- Optional
- optional
- Description
ISO 8601 expiry timestamp. After this moment the link is
expiredand rejects all checkout attempts. Omit or set tonullfor a non-expiring link.
|- Name
redirect_url- Type
- string
- Optional
- optional
- Description
Where to redirect the customer after a successful payment. If omitted, the customer is sent to a default TapTree thank-you page that includes the payment id. Helpful for tying the success URL to your order management system.
Create-time only —
redirect_urlcannot be changed via Update (it is one of the immutable fields). It is returned in Retrieve / List / Update responses and in webhook payloads.
|- Name
internal_reference- Type
- string
- Optional
- optional
- Description
Your own external reference. Useful for joining payments to your internal order ids. Up to 255 characters. Returned in Retrieve / List / Update responses and in webhook payloads. Mutable via Update — see the Update endpoint for the tri-state semantics (omit /
null/ string).
|- Name
collect_email- Type
- boolean
- Optional
- optional
- Description
If
true, the checkout page asks the customer for their email. Captured in the payment'scustomer.emailfield. Defaults tofalse.
|- Name
collect_address- Type
- boolean
- Optional
- optional
- Description
If
true, the checkout page asks the customer for a billing address. Captured in the payment'sbilling.addressfield. Defaults tofalse.
|- Name
collect_phone- Type
- boolean
- Optional
- optional
- Description
If
true, the checkout page asks the customer for a phone number. Defaults tofalse.
|
Idempotency
The Create endpoint is not yet idempotency-key aware — a double-POST creates two distinct payment-links. Until shared idempotency lands across the API, use client-side retry-with-dedup (e.g., generate a uuid client-side, store it, and only retry the original POST if you have not yet recorded a successful 2xx for that uuid).
Request
curl https://api.taptree.org/v1/payment_links \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{
"amount": { "value": "12.50", "currency": "EUR" },
"description": "Reservierung 4456",
"payments_limit": 1,
"expires_at": "2026-06-30T23:59:59Z",
"redirect_url": "https://example.com/thank-you?order=4456",
"internal_reference": "order-4456"
}'
Example response
{
"object": "payment_link",
"id": "pl_AncTwHXWLff",
"status": "active",
"amount": {
"value": 12.50,
"currency": "EUR"
},
"description": "Reservierung 4456",
"internal_reference": "order-4456",
"redirect_url": "https://example.com/thank-you?order=4456",
"payments_limit": 1,
"remaining_payments": 1,
"paid_count": 0,
"expires_at": "2026-06-30T23:59:59Z",
"expired_at": null,
"first_paid_at": null,
"last_paid_at": null,
"created_at": "2026-05-23T15:42:11.819Z",
"org_id": "org_5A8hsNqGGyW",
"acceptor_id": "acceptor_ABJZLqBvy7Y",
"links": {
"checkout": {
"href": "https://checkout.taptree.org/link/pl_AncTwHXWLff",
"type": "text/html"
}
}
}
Errors
| HTTP | When | What to do |
|---|---|---|
400 Bad Request | Request body failed schema validation (wrong type, missing required field, unknown field, format violation on expires_at / redirect_url, amount.value did not match the decimal pattern, payments_limit not an integer, etc.). | Fix the request shape per the documented schema. The detail field describes the specific violation. |
401 Unauthorized | API key missing, revoked, grace-expired, or its creator role was demoted. | Re-issue the key. |
403 Forbidden | API key lacks the payment_link:create scope, OR the creator user lost the org permission. | Grant the scope in the dashboard. |
422 Unprocessable Entity | amount.value missing or non-positive; acceptor_id missing on an org-scoped key; acceptor_id belongs to another org; admin-user creator (see Admin-user gotcha). | Check the attribute field in the response envelope and fix the input. |
503 Service Unavailable | Authentication infrastructure temporarily unavailable. | Safe to retry with exponential backoff. |
Retrieve a payment-link
To obtain the details of an already-created payment-link, request it by its unique pl_xxx id. The full payment-link object is returned, identical in shape to the create-response.
Required scope: payment_link:read.
Parameters
Replace the placeholder :id in the endpoint URL with the actual payment-link id. For example, to retrieve the link with id pl_AncTwHXWLff, your endpoint URL becomes /v1/payment_links/pl_AncTwHXWLff.
All available object details are documented in the payment-link object.
Request
curl https://api.taptree.org/v1/payment_links/pl_AncTwHXWLff \
-H "Authorization: Bearer {token}"
Example response
{
"object": "payment_link",
"id": "pl_AncTwHXWLff",
"status": "active",
"amount": {
"value": 12.50,
"currency": "EUR"
},
"description": "Reservierung 4456",
"internal_reference": "order-4456",
"redirect_url": "https://example.com/thank-you?order=4456",
"payments_limit": 1,
"remaining_payments": 1,
"paid_count": 0,
"expires_at": "2026-06-30T23:59:59Z",
"expired_at": null,
"first_paid_at": null,
"last_paid_at": null,
"created_at": "2026-05-23T15:42:11.819Z",
"org_id": "org_5A8hsNqGGyW",
"acceptor_id": "acceptor_ABJZLqBvy7Y",
"links": {
"checkout": {
"href": "https://checkout.taptree.org/link/pl_AncTwHXWLff",
"type": "text/html"
}
}
}
Errors
| HTTP | When | What to do |
|---|---|---|
401 Unauthorized | API key missing or invalid. | Re-issue the key. |
403 Forbidden | API key lacks payment_link:read. | Grant the scope. |
404 Not Found | The id is unknown, malformed, OR belongs to a different organization than your API key. The two cases return the same response on purpose — TapTree does not disclose the existence of resources across organization boundaries. | Verify the id and that the key belongs to the same org. |
Update a payment-link
Updates one or more fields on an existing payment-link. Use this endpoint to deactivate or reactivate the link — there is no DELETE endpoint, status-toggle is the canonical retirement path.
Required scope: payment_link:update.
Why POST and not PUT/PATCH?
The Update endpoint is POST /v1/payment_links/:id, not PUT or PATCH. This is partial-update semantics over POST, with a single endpoint per resource id. Send only the fields you want to change; omitted fields are preserved.
Parameters
Replace :id in the endpoint URL with the payment-link's id (e.g. pl_AncTwHXWLff). The request body must contain at least one updatable field.
Updatable attributes
- Name
status- Type
- string
- Optional
- optional
- Description
Toggle between
activeandinactive. Sending any other value (including"expired") returns400 Bad Requestfrom the Fastify schema enum check; the SQL layer additionally raises a422 Unprocessable Entitywithattribute: "status"anddetail: "status must be one of active, inactive"as defense-in-depth. Once a link is terminallyexpired, attempting to setactiveorinactivereturns422 Unprocessable Entitywithdetail: "Status cannot be changed once expired".Allowed values:
activeinactive
|- Name
description- Type
- string
- Optional
- optional
- Description
Replace the description text. Pass an empty string to overwrite with a blank value. Passing
nullpreserves the existing description (it is not interpreted as a clear instruction).
|- Name
payments_limit- Type
- integer
- Optional
- optional
- Description
Adjust the payment cap. Must be
>= 1. Settingpayments_limitlower than the currentpaid_countis rejected with422 Unprocessable Entity(attribute: "payments_limit",detail: "payments_limit cannot be set below the count of payments already completed"). Setting it equal topaid_countis allowed — the link is then at-capacity,remaining_paymentsis recomputed to0, and the checkout-gate denies further attempts withreason: "limit_reached". (Note: the auto-inactivate transition fires from the payment-paid path itself, not from the update path; lowering the cap to matchpaid_countdoes not retroactively setstatustoinactive— if you want the link inactive, sendstatus: "inactive"in the same update.) Raising the cap above the currentpaid_countre-opens room and recomputesremaining_paymentsautomatically.Pass
nullto remove the cap entirely (the link becomes unlimited again). Clearing the cap is always allowed regardless ofpaid_count.
|- Name
expires_at- Type
- datetime
- Optional
- optional
- Description
Replace the expiry timestamp. Pass
nullto clear the expiry. Note: oncestatusisexpiredyou cannot un-expire by changing this field.
|- Name
internal_reference- Type
- string
- Optional
- optional
- Description
Replace your external reference. Tri-state semantics:
- Omit the key entirely → existing value is preserved (no change).
- Pass
null→ the field is cleared (set back tonull). Only this field is touched —redirect_urland any other create-time technical fields survive. - Pass a string → replaces the current value with the new one. Up to 255 characters.
|
Immutable fields. amount, acceptor_id, redirect_url, collect_email, collect_address, and collect_phone cannot be updated after creation. The Fastify schema does not accept them in the Update body (additionalProperties: false — sending them returns 400 Bad Request). If you need different values, create a new payment-link.
Common patterns
- Deactivate a link:
{ "status": "inactive" }— preserves history, blocks new payments. - Reactivate a paused link:
{ "status": "active" }— only works if the link is currentlyinactive(notexpired). - Extend the expiry:
{ "expires_at": "2026-09-01T00:00:00Z" }— only meaningful while stillactive. - Raise the cap mid-flight:
{ "payments_limit": 50 }—remaining_paymentsis recomputed automatically frompayments_limit - paid_count. Lowering the cap belowpaid_countis rejected with422.
Request — Deactivate
curl -X POST https://api.taptree.org/v1/payment_links/pl_AncTwHXWLff \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{ "status": "inactive" }'
Example response
{
"object": "payment_link",
"id": "pl_AncTwHXWLff",
"status": "inactive",
"amount": {
"value": 12.50,
"currency": "EUR"
},
"description": "Reservierung 4456",
"internal_reference": "order-4456",
"redirect_url": "https://example.com/thank-you?order=4456",
"payments_limit": 1,
"remaining_payments": 1,
"paid_count": 0,
"expires_at": null,
"expired_at": null,
"first_paid_at": null,
"last_paid_at": null,
"created_at": "2026-05-23T15:42:11.819Z",
"org_id": "org_5A8hsNqGGyW",
"acceptor_id": "acceptor_ABJZLqBvy7Y",
"links": {
"checkout": {
"href": "https://checkout.taptree.org/link/pl_AncTwHXWLff",
"type": "text/html"
}
}
}
Errors
| HTTP | When | What to do |
|---|---|---|
400 Bad Request | Request body failed schema validation (wrong type, unknown field — including immutable fields like amount or acceptor_id — status not in ['active', 'inactive'], format violation, etc.). | Fix the request shape per the documented schema. The detail field describes the specific violation. |
401 Unauthorized | API key missing or invalid. | Re-issue the key. |
403 Forbidden | API key lacks payment_link:update. | Grant the scope. |
404 Not Found | Link not found, or belongs to another org. | Check the id and the API key's org. |
422 Unprocessable Entity | Empty request body; status not in {active, inactive} (defense-in-depth on top of the schema 400, attribute: "status"); payments_limit set below current paid_count (attribute: "payments_limit"); status-transition rejected (expired → active/inactive returns detail: "Status cannot be changed once expired"); other SQL-layer business-state violations. | Inspect the detail and attribute fields, fix and retry. |
List all payment-links
Retrieve a paginated list of your organization's payment-links, newest first. Use limit and starting_after for cursor pagination, and status to filter by lifecycle state.
Required scope: payment_link:read.
The response is a list object: { object: "list", data: [...], has_more: boolean }.
Pagination model
Forward-cursor pagination. Pass the previous page's last id as starting_after to fetch the next page. There is no backward cursor (ending_before is not supported on this endpoint — documenting it would be a lie since the underlying SQL has no backward-cursor path).
Optional query parameters
- Name
limit- Type
- integer
- Optional
- optional
- Default
- default=10
- Description
Maximum number of payment-links to return in one response. Range:
1to100. Defaults to10.
||- Name
starting_after- Type
- string
- Optional
- optional
- Description
A payment-link
idthat acts as a cursor for forward pagination. Returns links listed after thisid. Useful when paginating through many links.If your last retrieved link
idispl_AncTwHXWLff, passstarting_after=pl_AncTwHXWLffin the next request to fetch the following page.
|- Name
status- Type
- string
- Optional
- optional
- Description
Filter by status. One of
active,inactive,expired. Omit to return links of all statuses.
|
Request
curl -G https://api.taptree.org/v1/payment_links \
-H "Authorization: Bearer {token}" \
-d limit=20 \
-d status=active
Example response
{
"object": "list",
"data": [
{
"object": "payment_link",
"id": "pl_AncTwHXWLff",
"status": "active",
"amount": {
"value": 12.50,
"currency": "EUR"
},
"description": "Reservierung 4456",
"internal_reference": "order-4456",
"redirect_url": "https://example.com/thank-you?order=4456",
"payments_limit": 1,
"remaining_payments": 1,
"paid_count": 0,
"expires_at": "2026-06-30T23:59:59Z",
"created_at": "2026-05-23T15:42:11.819Z",
"org_id": "org_5A8hsNqGGyW",
"acceptor_id": "acceptor_ABJZLqBvy7Y",
"links": {
"checkout": {
"href": "https://checkout.taptree.org/link/pl_AncTwHXWLff",
"type": "text/html"
}
}
},
{
"object": "payment_link",
"id": "pl_2Xs7s4wm5kD",
"status": "expired",
"amount": {
"value": 50.00,
"currency": "EUR"
},
"description": "Eintritt Sommerfest 2025",
"paid_count": 38,
"first_paid_at": "2025-05-01T10:14:22.000Z",
"last_paid_at": "2025-08-12T19:47:01.000Z",
"expired_at": "2025-09-01T00:00:00.000Z",
"created_at": "2025-04-15T08:00:00.000Z"
// ... additional fields
}
// ... additional payment-links
],
"has_more": true
}
Errors
| HTTP | When | What to do |
|---|---|---|
400 Bad Request | Query string failed schema validation (status not in ['active', 'inactive', 'expired'], unknown query field, etc.). | Fix the query parameter per the documented schema. |
401 Unauthorized | API key missing or invalid. | Re-issue the key. |
403 Forbidden | API key lacks payment_link:read. | Grant the scope. |
422 Unprocessable Entity | limit not a positive integer or out of 1..100; starting_after malformed. | Fix the query parameter. |
List payments for a payment-link
Returns the payments customers have made through this payment-link, newest first. This sub-resource is the canonical way to reconcile a link with its outcomes — useful for "how much did this QR-code earn?" reports, fulfillment workflows that key off a specific link, or simply auditing.
Required scope: payment_link:read — the parent link's scope. You do not need payments:read to call this endpoint. The sub-resource is gated by its parent.
Minimal payment shape
Payments returned here come in a deliberately minimal shape: id, status, amount, created_at, paid_at, plus the payment_link_id back-reference. This is the right shape for reconciliation dashboards and report rendering. If you need the full payment object (customer details, payment method, refund history, environmental impact, etc.), call GET /v1/payments/:id separately — that endpoint requires payments:read because it is a different resource boundary.
Why a sub-resource?
The same payments are also accessible via GET /v1/payments?... with a query filter, but GET /v1/payment_links/:id/payments is preferable when your mental model is "this link, those payments":
- The scope check is simpler — one scope (
payment_link:read), not two. - The pagination cursor is meaningful within the link's history (newest payment for THIS link first), not within the whole org's payment stream.
- The cross-org probe behavior is consistent with the parent endpoint — a
pl_xxxin another org returns404, never leaks existence.
Pagination model
Forward-cursor pagination. Pass the previous page's last payment id (e.g. pay_3xQ9KvMnB8r) as starting_after. ending_before is not supported.
Query parameters
- Name
limit- Type
- integer
- Optional
- optional
- Default
- default=10
- Description
Maximum number of payments to return. Range:
1to100. Defaults to10.
||- Name
starting_after- Type
- string
- Optional
- optional
- Description
A payment
idcursor for forward pagination. Pass the previous page's last paymentidto fetch the following page.
|
This endpoint does not accept a status filter — the Fastify schema rejects it as an unknown query field with 400 Bad Request. For filtering payments by status, use GET /v1/payments?status=... (parent acceptor's scope) instead. The sub-resource is kept intentionally narrow to avoid conflating payment-status filtering with payment-link lifecycle semantics.
Request
curl -G https://api.taptree.org/v1/payment_links/pl_AncTwHXWLff/payments \
-H "Authorization: Bearer {token}" \
-d limit=10
Example response
{
"object": "list",
"data": [
{
"object": "payment",
"id": "pay_3xQ9KvMnB8r",
"status": "paid",
"amount": {
"value": "12.50",
"currency": "EUR"
},
"created_at": "2026-05-23T15:48:02.114Z",
"paid_at": "2026-05-23T15:48:38.302Z",
"payment_link_id": "pl_AncTwHXWLff"
},
{
"object": "payment",
"id": "pay_2YbXMnVPq4K",
"status": "failed",
"amount": {
"value": "12.50",
"currency": "EUR"
},
"created_at": "2026-05-23T14:11:09.522Z",
"paid_at": null,
"payment_link_id": "pl_AncTwHXWLff"
}
],
"has_more": false
}
Errors
| HTTP | When | What to do |
|---|---|---|
400 Bad Request | Query string failed schema validation (unknown field, limit of wrong type, etc.). | Fix the query parameter per the documented schema. |
401 Unauthorized | API key missing or invalid. | Re-issue the key. |
403 Forbidden | API key lacks payment_link:read. | Grant the scope. |
404 Not Found | The payment-link id is unknown, malformed, or belongs to another org. | Verify the id and the API key's org. |
422 Unprocessable Entity | limit not a positive integer or out of 1..100. | Fix the query parameter. |
Error envelope
Every error from the Payment-Links API returns the canonical TapTree envelope. The shape is stable across the entire API surface — same fields, same semantics — so a single client-side error handler works everywhere.
Error envelope
{
"status": 422,
"type": "Unprocessable Entity",
"detail": "amount.value is required",
"attribute": "amount.value",
"links": {
"documentation": {
"href": "https://docs.taptree.org/errors",
"type": "text/html"
}
}
}
| Field | Type | Notes |
|---|---|---|
status | integer | The HTTP status code, mirrored in the body for clients that drop the response code. |
type | string | Human-readable label: Unauthorized, Forbidden, Not Found, Unprocessable Entity, Conflict, Internal Server Error. |
detail | string | The actionable message. Never echoes raw SQL or internal table names — those are logged server-side, with a generic copy returned to the client. |
attribute | string (optional) | The offending input field for 422 validation errors, e.g. amount.value, acceptor_id, payments_limit. |
links.documentation | object | A pointer to the relevant docs page. |
Status-code matrix (all endpoints)
| HTTP | Meaning | Where it can come from |
|---|---|---|
200 OK | Successful read or update. | Retrieve, Update, List, List payments. |
201 Created | Successful create. | Create only. |
400 Bad Request | Malformed JSON body, OR request failed Fastify schema validation (wrong field type, unknown field, format violation on expires_at/redirect_url, amount.value pattern mismatch, status not in the allowed enum, etc.). | Any endpoint. |
401 Unauthorized | API key missing, revoked, grace-expired, or its creator role was demoted. | Any endpoint. |
403 Forbidden | API key lacks the required scope (route-layer reject), OR the creator user lost the org permission for the action. | Any endpoint. |
404 Not Found | Payment-link id unknown OR a payment-link in another organization — same response either way, no existence-disclosure across orgs. | Retrieve, Update, List payments. |
409 Conflict | Customer hit the checkout page for an inactive, expired, or limit-reached link. (Returned by the checkout-gate, not the API endpoints documented here, but appears in customer-facing flows.) | Checkout (out-of-band). |
422 Unprocessable Entity | SQL-layer business-state violation — amount.value non-positive, invalid id/cursor format, status not in {active, inactive} (defense-in-depth on top of the schema 400), payments_limit set below current paid_count, expired → active/inactive transition rejected ("Status cannot be changed once expired"), acceptor not in your organization, admin-user creator. Note: shape-level validation errors (wrong type, unknown field, format violation) still come through as 400, not 422. | Create, Update, List. |
500 Internal Server Error | Unexpected server error. The detail is intentionally generic; server logs carry the SQL-level context. | Any endpoint. |
503 Service Unavailable | Authentication infrastructure temporarily unavailable. | Any endpoint. Safe to retry with exponential backoff. |
Webhooks
Payment-link lifecycle events flow through the standard webhooks system. Subscribe by configuring a static endpoint in the dashboard under Entwickler → Webhooks and subscribing to transactions.payment_link.* (or a more selective set — see the Email-related events note above to avoid noise from the email.* family).
Per-link webhook URLs are not currently supported by the public API. The Create body schema rejects unknown fields (additionalProperties: false), so sending webhook_url at create time returns 400 Bad Request.
Event types emitted
The payment-link-scoped subset of the canonical event catalog. See Webhooks → Event Types for the cross-resource catalog and wildcard-subscription mechanics.
| Event type | When | Aggregate type in data |
|---|---|---|
transactions.payment_link.created | A new link is created. | payment_link |
transactions.payment_link.properties.changed | Any update via POST /v1/payment_links/:id, including status toggles, limit changes, expiry changes. Does not fire on counter movements. | payment_link |
transactions.payment_link.expired | The link transitions to expired (cron-driven, or detected at checkout). | payment_link |
transactions.payment_link.payment.created | A new payment was created against the link. | payment_link; payload includes payment_id. |
transactions.payment_link.payment.reused | A still-open payment for the same client-key was reused (idempotent checkout). | payment_link; payload includes payment_id. |
transactions.payment_link.remaining_payments.decremented | After a successful payment is recorded against a limited link, when remaining_payments > 0 before the decrement. | payment_link; payload includes payment_id and the new remaining_payments. |
transactions.payment_link.checkout.requested | Checkout page opened for this link. | payment_link |
transactions.payment_link.checkout.denied | Checkout opened but rejected. data.reason is inactive (link's status is not active), expired (was active but expires_at just elapsed), or limit_reached (counter exhausted). | payment_link |
For the canonical event envelope (id, type, triggered_at, version, mode, data), HMAC verification, and the signature-algo matrix, see the Webhooks page.
Example: payment_link.properties.changed
{
"id": "evt_DhMjK8xVqLnR9p4",
"object": "event",
"type": "transactions.payment_link.properties.changed",
"triggered_at": "2026-05-23T16:22:11.401Z",
"version": "2026-05-16",
"mode": "live",
"data": {
"object": "payment_link",
"id": "pl_AncTwHXWLff",
"status": "inactive",
"amount": { "value": 12.50, "currency": "EUR" },
"description": "Reservierung 4456",
"internal_reference": "order-4456",
"redirect_url": "https://example.com/thank-you?order=4456",
"payments_limit": 1,
"remaining_payments": 1,
"paid_count": 0,
"created_at": "2026-05-23T15:42:11.819Z",
"org_id": "org_5A8hsNqGGyW",
"acceptor_id": "acceptor_ABJZLqBvy7Y",
"links": {
"checkout": {
"href": "https://checkout.taptree.org/link/pl_AncTwHXWLff",
"type": "text/html"
}
}
}
}
Testing
Hitting the limit and the expiry from tests
To exercise the limit-reached path: create a link with payments_limit: 1, complete one successful payment in checkout, then GET /v1/payment_links/:id — status will be inactive, remaining_payments will be 0, and you will have received both a transactions.payment.paid event and a transactions.payment_link.auto_inactivated event (the latter fires in the same database statement as the former, with data.reason: "limit_reached"). Opening the checkout URL again returns HTTP 409 with a transactions.payment_link.checkout.denied event whose data.reason is inactive (status-first precedence — the link's status is now inactive).
To exercise expired quickly: create with expires_at a minute in the future, wait up to a minute for the cron job to fire (* * * * *), then retrieve — status will be expired and expired_at will be set.
No DELETE endpoint
There is no DELETE /v1/payment_links/:id endpoint. This is intentional. Reasons:
- History is the audit trail. Real money flowed through this link; deleting it deletes the receipt.
- Status-managed lifecycle is more expressive. A deleted link cannot answer "was it ever active?", "how many paid?", "when did the last payment land?". An inactive or expired link can.
- Dashboards stay reconcilable. When you look at last quarter's settlements, you want to be able to drill down into the originating link — even if you have long since retired it.
To retire a link, set status: "inactive". If you need it gone from default dashboard lists, the dashboard filters by status — inactive and expired links are filtered out unless you opt in to see them.