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 Collection

Endpoints

POST/v1/payment_links

Create a new payment-link your customers can pay against.

GET/v1/payment_links/:id

Fetch the full details of an existing payment-link.

POST/v1/payment_links/:id

Update fields on an existing payment-link, including the status toggle (activate/deactivate).

GET/v1/payment_links

Retrieve a list of payment-links with optional status filter and cursor pagination.

GET/v1/payment_links/:id/payments

List the payments customers have made through a specific payment-link.

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.

deactivatereactivateexpires_at elapsedor limit reachedactiveinactiveexpired
StatusDescriptionHow it's reachedReversible?
activeThe 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)
inactivePaused — 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"
expiredTerminal — 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

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 typeWhen it firesAggregate
transactions.payment_link.createdA new link is created via POST /v1/payment_linksThe new payment_link
transactions.payment_link.properties.changedAny update via POST /v1/payment_links/:id (including status toggles)The updated payment_link
transactions.payment_link.expiredCron-driven or checkout-gate-driven transition into expiredThe expired payment_link
transactions.payment_link.payment.createdA new payment was created against this link (customer hit checkout)The link; payload carries the new payment_id
transactions.payment_link.payment.reusedAn 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.decrementedFires 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_inactivatedThe 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.requestedCheckout page was opened against this linkThe link
transactions.payment_link.checkout.deniedCheckout 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

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: active inactive expired

    See 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 the currency.

  • Name
    description
    Type
    string
    Description

    Free-form short text shown on the checkout page and surfaced in dashboards and webhooks. null if 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). null if 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. null falls 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.

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 a transactions.payment_link.remaining_payments.decremented event; once remaining_payments reaches zero, the link automatically transitions to inactive and a transactions.payment_link.auto_inactivated event is emitted in the same database statement as the final transactions.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. null while payments_limit is null.

  • 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 0 on create.

  • Name
    first_paid_at
    Type
    datetime
    |
    Optional
    optional
    Description

    Timestamp of the first successful payment via this link, in ISO 8601 format. null until a payment first reaches paid state.

  • 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 expired and rejects further checkouts. null means no expiry.

  • Name
    expired_at
    Type
    datetime
    |
    Optional
    optional
    Description

    ISO 8601 timestamp at which the link actually transitioned to expired. null until expiry fires.

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:

ActionScope tokenEndpoints
Createpayment_link:createPOST /v1/payment_links
Readpayment_link:readGET /v1/payment_links, GET /v1/payment_links/:id, GET /v1/payment_links/:id/payments
Updatepayment_link:updatePOST /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.

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_id defaults to the key's bound acceptor on create; it can be overridden to any acceptor in the same organization. Cross-org acceptor references return 422 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_id is 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).


POST/v1/payment_links

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 the currency.

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 >= 1 if specified. Omit or set to null for an unlimited link. Once remaining_payments reaches zero, the link automatically transitions to inactive (a transactions.payment_link.auto_inactivated webhook fires alongside the triggering transactions.payment.paid), and the checkout-gate denies further attempts with reason: "limit_reached".

  • Name
    expires_at
    Type
    datetime
    |
    Optional
    optional
    Description

    ISO 8601 expiry timestamp. After this moment the link is expired and rejects all checkout attempts. Omit or set to null for 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_url cannot 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's customer.email field. Defaults to false.

  • Name
    collect_address
    Type
    boolean
    |
    Optional
    optional
    Description

    If true, the checkout page asks the customer for a billing address. Captured in the payment's billing.address field. Defaults to false.

  • Name
    collect_phone
    Type
    boolean
    |
    Optional
    optional
    Description

    If true, the checkout page asks the customer for a phone number. Defaults to false.

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

POST
/v1/payment_links
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

HTTPWhenWhat to do
400 Bad RequestRequest 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 UnauthorizedAPI key missing, revoked, grace-expired, or its creator role was demoted.Re-issue the key.
403 ForbiddenAPI key lacks the payment_link:create scope, OR the creator user lost the org permission.Grant the scope in the dashboard.
422 Unprocessable Entityamount.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 UnavailableAuthentication infrastructure temporarily unavailable.Safe to retry with exponential backoff.

GET/v1/payment_links/:id

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.

Request

GET
/v1/payment_links/pl_AncTwHXWLff
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

HTTPWhenWhat to do
401 UnauthorizedAPI key missing or invalid.Re-issue the key.
403 ForbiddenAPI key lacks payment_link:read.Grant the scope.
404 Not FoundThe 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.

POST/v1/payment_links/:id

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 active and inactive. Sending any other value (including "expired") returns 400 Bad Request from the Fastify schema enum check; the SQL layer additionally raises a 422 Unprocessable Entity with attribute: "status" and detail: "status must be one of active, inactive" as defense-in-depth. Once a link is terminally expired, attempting to set active or inactive returns 422 Unprocessable Entity with detail: "Status cannot be changed once expired".

    Allowed values: active inactive

  • Name
    description
    Type
    string
    |
    Optional
    optional
    Description

    Replace the description text. Pass an empty string to overwrite with a blank value. Passing null preserves 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. Setting payments_limit lower than the current paid_count is rejected with 422 Unprocessable Entity (attribute: "payments_limit", detail: "payments_limit cannot be set below the count of payments already completed"). Setting it equal to paid_count is allowed — the link is then at-capacity, remaining_payments is recomputed to 0, and the checkout-gate denies further attempts with reason: "limit_reached". (Note: the auto-inactivate transition fires from the payment-paid path itself, not from the update path; lowering the cap to match paid_count does not retroactively set status to inactive — if you want the link inactive, send status: "inactive" in the same update.) Raising the cap above the current paid_count re-opens room and recomputes remaining_payments automatically.

    Pass null to remove the cap entirely (the link becomes unlimited again). Clearing the cap is always allowed regardless of paid_count.

  • Name
    expires_at
    Type
    datetime
    |
    Optional
    optional
    Description

    Replace the expiry timestamp. Pass null to clear the expiry. Note: once status is expired you 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 to null). Only this field is touched — redirect_url and any other create-time technical fields survive.
    • Pass a string → replaces the current value with the new one. Up to 255 characters.

Common patterns

  • Deactivate a link: { "status": "inactive" } — preserves history, blocks new payments.
  • Reactivate a paused link: { "status": "active" } — only works if the link is currently inactive (not expired).
  • Extend the expiry: { "expires_at": "2026-09-01T00:00:00Z" } — only meaningful while still active.
  • Raise the cap mid-flight: { "payments_limit": 50 }remaining_payments is recomputed automatically from payments_limit - paid_count. Lowering the cap below paid_count is rejected with 422.

Request — Deactivate

POST
/v1/payment_links/pl_AncTwHXWLff
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

HTTPWhenWhat to do
400 Bad RequestRequest body failed schema validation (wrong type, unknown field — including immutable fields like amount or acceptor_idstatus not in ['active', 'inactive'], format violation, etc.).Fix the request shape per the documented schema. The detail field describes the specific violation.
401 UnauthorizedAPI key missing or invalid.Re-issue the key.
403 ForbiddenAPI key lacks payment_link:update.Grant the scope.
404 Not FoundLink not found, or belongs to another org.Check the id and the API key's org.
422 Unprocessable EntityEmpty 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.

GET/v1/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: 1 to 100. Defaults to 10.

  • Name
    starting_after
    Type
    string
    |
    Optional
    optional
    Description

    A payment-link id that acts as a cursor for forward pagination. Returns links listed after this id. Useful when paginating through many links.

  • Name
    status
    Type
    string
    |
    Optional
    optional
    Description

    Filter by status. One of active, inactive, expired. Omit to return links of all statuses.

Request

GET
/v1/payment_links
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

HTTPWhenWhat to do
400 Bad RequestQuery string failed schema validation (status not in ['active', 'inactive', 'expired'], unknown query field, etc.).Fix the query parameter per the documented schema.
401 UnauthorizedAPI key missing or invalid.Re-issue the key.
403 ForbiddenAPI key lacks payment_link:read.Grant the scope.
422 Unprocessable Entitylimit not a positive integer or out of 1..100; starting_after malformed.Fix the query parameter.

GET/v1/payment_links/:id/payments

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_xxx in another org returns 404, 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: 1 to 100. Defaults to 10.

  • Name
    starting_after
    Type
    string
    |
    Optional
    optional
    Description

    A payment id cursor for forward pagination. Pass the previous page's last payment id to fetch the following page.

Request

GET
/v1/payment_links/pl_AncTwHXWLff/payments
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

HTTPWhenWhat to do
400 Bad RequestQuery string failed schema validation (unknown field, limit of wrong type, etc.).Fix the query parameter per the documented schema.
401 UnauthorizedAPI key missing or invalid.Re-issue the key.
403 ForbiddenAPI key lacks payment_link:read.Grant the scope.
404 Not FoundThe payment-link id is unknown, malformed, or belongs to another org.Verify the id and the API key's org.
422 Unprocessable Entitylimit 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"
    }
  }
}
FieldTypeNotes
statusintegerThe HTTP status code, mirrored in the body for clients that drop the response code.
typestringHuman-readable label: Unauthorized, Forbidden, Not Found, Unprocessable Entity, Conflict, Internal Server Error.
detailstringThe actionable message. Never echoes raw SQL or internal table names — those are logged server-side, with a generic copy returned to the client.
attributestring (optional)The offending input field for 422 validation errors, e.g. amount.value, acceptor_id, payments_limit.
links.documentationobjectA pointer to the relevant docs page.

Status-code matrix (all endpoints)

HTTPMeaningWhere it can come from
200 OKSuccessful read or update.Retrieve, Update, List, List payments.
201 CreatedSuccessful create.Create only.
400 Bad RequestMalformed 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 UnauthorizedAPI key missing, revoked, grace-expired, or its creator role was demoted.Any endpoint.
403 ForbiddenAPI key lacks the required scope (route-layer reject), OR the creator user lost the org permission for the action.Any endpoint.
404 Not FoundPayment-link id unknown OR a payment-link in another organization — same response either way, no existence-disclosure across orgs.Retrieve, Update, List payments.
409 ConflictCustomer 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 EntitySQL-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 ErrorUnexpected server error. The detail is intentionally generic; server logs carry the SQL-level context.Any endpoint.
503 Service UnavailableAuthentication 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).

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 typeWhenAggregate type in data
transactions.payment_link.createdA new link is created.payment_link
transactions.payment_link.properties.changedAny 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.expiredThe link transitions to expired (cron-driven, or detected at checkout).payment_link
transactions.payment_link.payment.createdA new payment was created against the link.payment_link; payload includes payment_id.
transactions.payment_link.payment.reusedA still-open payment for the same client-key was reused (idempotent checkout).payment_link; payload includes payment_id.
transactions.payment_link.remaining_payments.decrementedAfter 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.requestedCheckout page opened for this link.payment_link
transactions.payment_link.checkout.deniedCheckout 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/:idstatus 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.

Was this page helpful?