> ## Documentation Index
> Fetch the complete documentation index at: https://www.pickfu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Publishing and payments

> How v2 quotes surveys, spends credits, charges saved cards, sells credit packs on Stripe Checkout, and hands off card management to the Stripe Billing Portal.

v2 splits publishing into a **quote** you can read at any time and a **publish** call that commits to it. A draft survey costs nothing until you publish; publish pays for it in one call.

## Quotes

Every `GET /v2/surveys/{id}` (and every `POST /v2/surveys` / `PATCH /v2/surveys/{id}` response) includes:

```json theme={null}
{
  "cost": {
    "amount": 40.00,
    "credits": 20,
    "discounts": [ /* line items */ ],
    "quotedAt": "2026-09-10T18:22:00.000Z"
  },
  "creditBalance": {
    "object": "credit_balance",
    "credits": 12,
    "amount": 24.00,
    "currency": "USD",
    "coversCost": false
  }
}
```

* `amount` is the total USD price if you paid entirely on card.
* `credits` is the same price expressed in credits (1 credit = \$2 USD).
* `discounts` includes plan-level, country-level, and API discounts already applied — the same pricing engine as the PickFu app.
* `quotedAt` is when the price was calculated. The most recent quote you fetched is what publish will honour.

The quote is a **contract**. Publish compares the last quote it recorded against the current price and refuses if they diverge — see `quote_changed` below.

## Publishing

`POST /v2/surveys/{id}/publish` requires the `spend` scope and an `Idempotency-Key` header. In one call it:

1. Confirms the recorded quote still matches the current price (else `409 quote_changed`).
2. Spends credits first, oldest expiry first, from the team's credit balance.
3. Charges any shortfall off-session to the team's default saved card, on the survey order itself. There is no separate credit-pack order in this path — the card charge is split-tendered on the survey.
4. Marks the order paid and starts collection asynchronously.

A `200` response means **paid**, not live. Collection starts in the background; watch `status` on `GET /v2/surveys/{id}` (`in-progress` → `completed`) or listen for the [`survey.completed`](/docs/api-reference/v2/webhooks) webhook.

Successful response:

```json theme={null}
{
  "object": "survey_publish",
  "status": "paid",
  "orderId": "ord_01J9C2VZH8...",
  "creditsUsed": 12,
  "cardCharged": 16.00,
  "amount": 40.00,
  "currency": "USD",
  "survey": { /* full survey resource */ }
}
```

## `402 insufficient_credits` — buy a pack, then retry

If credits don't cover the price and the team has **no saved card**, publish answers `402 insufficient_credits`. Nothing is charged and the survey is still a draft.

```json theme={null}
{
  "error": {
    "type": "card_error",
    "code": "insufficient_credits",
    "message": "Credits do not cover the balance and no card is on file.",
    "shortfall": { "amount": 40.00, "credits": 20 },
    "suggestedPack": {
      "pack": "credits_20",
      "credits": 20,
      "amount": 40.00,
      "currency": "USD"
    }
  },
  "requestId": "req_..."
}
```

To recover:

1. Buy the suggested pack: `POST /v2/orders` with `{ "pack": "credits_20" }` and a fresh Idempotency-Key.
2. Send the buyer to the returned `checkoutUrl` to pay by card on Stripe's hosted page.
3. Wait for the order to move to `status: "paid"` — poll `GET /v2/orders/{id}` or listen for the [`order.paid`](/docs/api-reference/v2/webhooks) webhook.
4. Retry `POST /v2/surveys/{id}/publish` with a **new** Idempotency-Key.

## `402 card_declined` / `card_authentication_required`

If credits don't cover the price and the saved card can't be charged off-session, publish answers `402 card_declined` or `402 card_authentication_required` with a `billingPortalUrl`:

```json theme={null}
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "message": "The saved card was declined.",
    "shortfall": { "amount": 40.00, "credits": 20 },
    "billingPortalUrl": "https://billing.stripe.com/p/session/...",
    "declineCode": "insufficient_funds"
  },
  "requestId": "req_..."
}
```

Send the buyer to `billingPortalUrl` to add or update a card, then publish again with a new Idempotency-Key.

## `409 quote_changed`

If the price moved between your last quote and the publish call, publish refuses to charge:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "quote_changed",
    "message": "The price changed since the last quote.",
    "quotedCost": { "amount": 40.00, "credits": 20, "quotedAt": "..." },
    "currentCost": { "amount": 44.00, "credits": 22 }
  },
  "requestId": "req_..."
}
```

Re-fetch the survey (`GET /v2/surveys/{id}`) to record the new quote, show it to the buyer, and publish again with a new Idempotency-Key.

## Credit packs

`POST /v2/orders` sells a credit pack on **Stripe Hosted Checkout**. PickFu never touches the card number.

```bash theme={null}
curl -X POST https://api.pickfu.com/v2/orders \
  -H "Authorization: Bearer sk_live_your_key_here" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "pack": "credits_20" }'
```

The response includes a `checkoutUrl`. Send the buyer to that URL; credits land on the team once Stripe confirms the payment. Then either poll `GET /v2/orders/{id}` until `status: "paid"` or listen for the `order.paid` webhook.

`GET /v2/orders` and `GET /v2/orders/{id}` list and read orders. Both credit-pack orders and survey orders appear; distinguish them by the resource fields returned per order.

An unknown `pack` value returns `400`.

## Managing saved cards

`POST /v2/billing-portal-sessions` opens a **Stripe Billing Portal** session for the team's Stripe customer:

```bash theme={null}
curl -X POST https://api.pickfu.com/v2/billing-portal-sessions \
  -H "Authorization: Bearer sk_live_your_key_here"
```

The response includes a portal URL. Send the buyer there to add, update, or remove saved cards. The `spend` scope is sufficient — there is no separate billing-admin gate.

## Credit balance

`GET /v2/credit-balance` returns the remaining credits and the underlying FIFO buckets (each with its expiry). Credits are spent oldest-expiry-first.


## Related topics

- [Errors](/docs/api-reference/v2/errors.md)
- [PickFu API v2](/docs/api-reference/v2/introduction.md)
- [Payments and billing](/help/payments-and-billing.md)
- [Pricing, payments, and subscriptions](/help/pricing-payments-and-subscriptions.md)
- [Is my payment information secure?](/help/is-my-payment-information-secure.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.