StimPay API · Bitcoin Lightning

Cash App Flows

Three flows for paying to and from Cash App over the Bitcoin Lightning Network: deposits by deeplink on mobile, deposits by QR on desktop, and withdrawals to a Lightning address.

Read this first

There is no “Cash App integration” here. Cash App is a Lightning wallet on the customer’s side. Everything below is standard Bitcoin Lightning — Cash App is simply the wallet most US customers pay from, and any Lightning wallet works identically.

Overview

Base URLs. Environments are fully separate — different databases, API keys and store IDs. A key only works in the environment it was created in.

EnvironmentBase URL
Productionhttps://api.getstimpay.com
Staginghttps://api-staging.getstimpay.com

Endpoints at a glance

Every endpoint used across the three flows — nothing else exists for these flows.

MethodPathPurpose
POST/api/v1/stores/{storeId}/invoicesCreate a deposit invoice
GET/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methodsLightning deeplink + Bolt11
GET/api/v1/stores/{storeId}/invoices/{invoiceId}Invoice status (informational)
POST/api/v1/stores/{storeId}/withdrawal/requestStart a USD payout → pullPaymentId
GET/api/v1/pull-payments/{pullPaymentId}Payout quote & status
POST/api/v1/stores/{storeId}/lnaddress-withdrawal/executeExecute the USD payout
POST/api/v1/stores/{storeId}/lnaddress-withdrawalBTC one-step payout (alternative)
GEThttps://cash.app/.well-known/lnurlp/{cashtag}Pre-validate an address (external, no auth)

Authentication

Send this header on every request — the literal word Token, a space, then your key. Never put credentials in the JSON body.

http
Authorization: Token YOUR_API_KEY

401 unauthenticated? The value must include the literal word Token before your key — Authorization: Token YOUR_API_KEY, not the key on its own. A bare key returns 401 {"code":"unauthenticated"}.

prod key ≠ staging key

store ID is per-environment

Amounts & metadata

Amounts

All amounts are decimal strings in whole currency units — never sats.

  • Fiat: 2 decimals — "25.00"
  • BTC: 8 decimals — "0.00012855"

Metadata & correlation

The metadata object is your correlation mechanism: it is echoed back in every webhook. Documented fields are OrderId, CustomerId, MerchantId; custom keys are also accepted.

Value rules

Values are restricted to alphanumerics, dashes and underscores — no spaces, no @ or .. An email-shaped value returns HTTP 400.

When correlating, match metadata keys case-insensitively — payloads may return orderId where you sent OrderId.


Flows 1 & 2

Deposits — deeplink (mobile) & QR (desktop)

Same API for both; only the client-side rendering differs. The customer sees USD — BTC conversion happens on our side, at a rate fixed at creation (source: CoinGecko).

  1. Create the invoice
    POST /api/v1/stores/{storeId}/invoices

    Plural invoices, no trailing slash — a trailing slash or the singular returns 405/404.

    bash
    curl -X POST https://api.getstimpay.com/api/v1/stores/{storeId}/invoices \
      -H "Authorization: Token YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "metadata": { "OrderId": "order-1001", "CustomerId": "user-42" },
        "checkout": { "paymentMethods": ["BTC-LightningNetwork"] },
        "amount": "25.00",
        "currency": "USD"
      }'

    The response contains the invoice id and status: "New". The id is all you need for the rest of this flow — you fetch the Lightning payment details with it (step 2) and poll its status for the outcome.

    Ignore — hosted-checkout only A few fields exist only to drive our hosted checkout page. This guide is about integrating the Cash App flow yourself — you render the payment (steps 2–3), so ignore them:

    • In the response: checkoutLink — the URL of our hosted page. Don’t link to it; you have your own UI.
    • In the request: checkout.redirectURL and checkout.redirectAutomatically — they only control what that hosted page does after payment, so leave them out (as above).
  2. Immediately fetch payment details
    GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods
    bash
    curl https://api.getstimpay.com/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods \
      -H "Authorization: Token YOUR_API_KEY"

    In the BTC-LightningNetwork entry (payments[0]):

    • destination — the raw Bolt11 invoice string.
    • paymentLink — a ready lightning:lnbc… deeplink.
  3. Render by device

    Desktop — render a QR code from destination; the user scans it with Cash App on their phone. Also show a copy button.

    Desktop gotcha Clicking a lightning: link on desktop with no wallet installed fails with “no registered handler.” That’s why desktop gets a QR, not the deeplink.

    What the customer sees You don’t need to display the conversion rate. The invoice is priced in USD and converted to BTC at a rate fixed when the invoice is created; when the customer scans the QR or taps the deeplink, Cash App reads the amount from the invoice and shows it before they confirm. Their Cash App may show a BTC/USD figure a few cents off yours — that’s normal: BTC is the settlement currency and the USD amount is informative only.

    Branch on the user-agent:

    javascript
    // Deposit rendering branches by device. Lightning deeplinks work on mobile
    // (the OS hands off to the installed wallet); on desktop with no wallet a
    // lightning: link fails with "no registered handler" — so desktop gets a QR.
    const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
    
    if (isMobile) {
      // paymentLink from the payment-methods response: "lightning:lnbc..."
      openCashAppButton.href = paymentLink;   // "Open Cash App"
      renderCopyInvoice(destination);         // always show the fallback
    } else {
      renderQR(destination);                  // scan with Cash App on a phone
      renderCopyButton(destination);
    }
  4. Expiry

    Invoices are valid 15 minutes by default; configurable per invoice via checkout.expirationMinutes (values up to at least 1440 accepted). Fetch payment details fresh at render time; create a new invoice if the user returns after expiry.

  5. Outcome — via webhooks only

    Register a callback URL; do not poll for the outcome. Payments are all-or-nothing on Lightning: the customer cannot underpay or overpay — underpaid invoices never settle, they expire.

    • InvoiceSettled — paid in full → credit the user.
    • InvoiceExpired — not paid in time.

    Informational status endpoint if you need it:

    GET /api/v1/stores/{storeId}/invoices/{invoiceId}
    bash
    curl https://api.getstimpay.com/api/v1/stores/{storeId}/invoices/{invoiceId} \
      -H "Authorization: Token YOUR_API_KEY"
    # status → New / Processing / Expired / Settled
  6. Webhook mechanics

    Each delivery carries the header BTCPay-Sig: sha256=<hex> — an HMAC-SHA256 of the exact raw request-body bytes, keyed with your webhook secret. Hash the bytes off the wire; never re-serialize the parsed JSON first.

    javascript
    import crypto from "node:crypto";
    
    // verify against the RAW body buffer — not a re-serialized object
    function verifySig(rawBodyBuf, sigHeader, webhookSecret) {
      const expected = "sha256=" + crypto
        .createHmac("sha256", webhookSecret)
        .update(rawBodyBuf)
        .digest("hex");
      return sigHeader === expected;   // mismatch → reject 401
    }
    
    // Return HTTP <400 to acknowledge. >=400 or a timeout triggers automatic
    // retries — make handlers idempotent and dedupe on deliveryId / isRedelivery.

    Webhook source IPs — whitelist these on your callback endpoint

    Webhook deliveries only come from the addresses below. Allow them on your callback endpoint and reject anything else, in addition to checking the signature.

    EnvironmentSource IPs
    Production3.70.34.254
    35.156.22.249
    3.64.170.117
    Staging3.72.145.25

Flow 3

Withdrawals to a Lightning address

Every Cash App user has a Lightning address of the form [email protected]. It’s reusable — collect it once and save it on the customer profile.

How it plays out end-to-end

The customer requests a withdrawal on the operator’s cashier page. The operator then runs their own fraud & withdrawal checks — minutes, hours, sometimes days — and only once approved do they call our API. Run the flow to see where that wait sits, and why the destination type matters:

Withdrawal lifecycle
  1. Customer requests withdrawal$50.00 → [email protected]
  2. Operator reviewInternal fraud & withdrawal checks
  3. Operator approves → calls StimPayPOST /withdrawal/request → execute
  4. SettlementPullPaymentSettled → paid to the customer

Approval delay A Lightning address and an L1 address stay valid indefinitely, so a slow approval is harmless. A customer-supplied bolt11 invoice has a short lifetime (15 min – 24 h) and can expire before the operator approves — the call then fails and you ask the customer for a fresh invoice. For withdrawals behind a manual approval step, prefer a Lightning address.

Recommended Pre-validate the address before you start, to save failed payouts.

GET https://cash.app/.well-known/lnurlp/{cashtag}
bash
curl https://cash.app/.well-known/lnurlp/{cashtag}
# HTTP 200 + JSON  → payable
# 404 / error      → typo, unverified Cash App account, or Cash App limits
#                    hit — ask the customer to double-check before submitting

USD-denominated payout — 3 steps

  1. Request the payout
    POST /api/v1/stores/{storeId}/withdrawal/request
    bash
    curl -X POST https://api.getstimpay.com/api/v1/stores/{storeId}/withdrawal/request \
      -H "Authorization: Token YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "amount": "50.00", "currency": "USD", "metadata": { "OrderId": "wd-1001" } }'
    # → returns pullPaymentId
  2. Read the quote & status
    GET /api/v1/pull-payments/{pullPaymentId}
    bash
    curl https://api.getstimpay.com/api/v1/pull-payments/{pullPaymentId} \
      -H "Authorization: Token YOUR_API_KEY"
    # → cryptoAmount, cryptoCurrency, rate, fiatAmount, fiatCurrency, status

    Show fiatAmount and the BTC cryptoAmount to the customer. status is new / pending / settled / expired; if it has expired, request a new withdrawal.

  3. Execute to the Lightning address
    POST /api/v1/stores/{storeId}/lnaddress-withdrawal/execute
    bash
    curl -X POST https://api.getstimpay.com/api/v1/stores/{storeId}/lnaddress-withdrawal/execute \
      -H "Authorization: Token YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "pullPaymentId": "{pullPaymentId}", "lnaddress": "[email protected]" }'

BTC-denominated payout — one-step alternative

POST /api/v1/stores/{storeId}/lnaddress-withdrawal
bash
curl -X POST https://api.getstimpay.com/api/v1/stores/{storeId}/lnaddress-withdrawal \
  -H "Authorization: Token YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "lnaddress": "[email protected]",
    "amount": "0.00012855",
    "currency": "BTC",
    "metadata": { "OrderId": "wd-1001" }
  }'

200 ≠ paid A 200 on execute means ACCEPTED, not PAID. The final state arrives via webhook:

  • PullPaymentSettled — paid.
  • PullPaymentCancelled — failed after retries.

Failed payments are retried automatically — 3 payment attempts within 15 minutes total; you don’t retry them yourself. If all attempts fail, the withdrawal is cancelled and PullPaymentCancelled is sent. Only after that event is it safe to create a new withdrawal for the same order.

Informational polling: GET /api/v1/pull-payments/{pullPaymentId} → new / pending / settled / expired.

Limits & requirements

  • The customer’s Cash App account must be verified — to send (deposits) and to receive (payouts).
  • Cash App caps Lightning at $999 per rolling 7-day window for receiving, and a separate $999 for sending.
  • No minimum on our side — 1 satoshi works.
  • No chargebacks on this rail. Every payment is an explicit signed action by the payer; there is no merchant-initiated, card-on-file or recurring pull — Lightning is push-only by design.

Webhook events by flow

Outcomes are delivered by webhook, not polling. See webhook mechanics for signature verification and retries.

FlowEventMeaningWhat to do
Deposits (1 & 2)InvoiceSettledPaid in fullCredit the user
InvoiceExpiredNot paid in time (incl. underpaid)Invoice is dead — create a new one if the customer returns
Withdrawals (3)PullPaymentSettledPayout paidMark the withdrawal complete
PullPaymentCancelledCancelled after automatic retries were exhaustedOnly now safe to create a new withdrawal for the same order

Every payload echoes your metadata and carries deliveryId / isRedelivery for dedupe. Example InvoiceSettled (deposit):

json
{
  "type": "InvoiceSettled",
  "currency": "USD",
  "cryptoAmount": 0.001,
  "fiatAmount": 25.00,
  "storeId": "{storeId}",
  "invoiceId": "{invoiceId}",
  "deliveryId": "KcaTiTbW3wizMoRy4LY2qY",
  "originalDeliveryId": "CPbfRrDc6RDUigbim8V4fd",
  "isRedelivery": false,
  "timestamp": 1717000000,
  "metadata": { "OrderId": "order-1001", "CustomerId": "user-42" }
}

Example PullPaymentSettled (withdrawal) carries pullPaymentId in place of invoiceId.

Testing

All integration testing happens on staging (Bitcoin regtest — fake money, separate credentials).

  • Deposits and withdrawals are tested with the Zeus wallet connected to our staging node (setup guide provided separately).
  • Lightning-address payouts on staging must target [email protected] (any name at that domain). Real-world addresses like cash.app exist only on mainnet and always fail on regtest.
  • Keep staging test amounts small — $1–10.
  • Staging webhooks come from 3.72.145.25 — allow it on your test callback endpoint.

Reminder Staging and production are fully separate — a staging key, store ID and address will not work in production, and vice versa.


StimPay Merchant Services Full API reference: api.getstimpay.com/docs (staging: api-staging.getstimpay.com/docs). Integration guide: docs.getstimpay.com.