Candor Payments is now open for US businesses that sell online. Open an account

Developers

Payment API and webhooks

Create checkouts, payment links, invoices and subscription plans from your server, and get a signed webhook for every payment, refund, dispute and payout. The API and webhooks cost nothing extra.

In short

  • Your API key is on your Candor account page, on the API and webhooks tab. It works from your server only.
  • Make a checkout, send your customer to its url, and confirm the payment with a webhook. Card numbers stay on Stripe's pages.
  • Everything you create is on your own Stripe account, at your usual price: 3.9% + 30¢ for a card payment on Simple.
  • No code? Your store's cart can send customers to your checkout address with the products in it.

Get started

  1. Make your API key. Sign in to your Candor account page and open the API and webhooks tab. Copy the key into your server's settings, such as an environment variable. Never put it in a web page, an app or your code repository.
  2. Create a checkout from your server with what the customer is buying, and send the customer to the checkout's url. They pay on Stripe's page, by card, Apple Pay or Google Pay.
  3. Confirm the payment on your server, with the checkout.completed webhook, or by getting the checkout and checking that payment_status is paid. Don't rely on the customer reaching your thank-you page: anyone can open that page without paying.
curl https://candorpayments.com/api/v1/checkout_sessions \
  -H "Authorization: Bearer $CANDOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{
    "line_items": [{ "name": "Linen candle, large", "amount": 3400, "quantity": 2 }],
    "success_url": "https://www.example.com/thanks?checkout={CHECKOUT_SESSION_ID}",
    "cancel_url": "https://www.example.com/cart",
    "client_reference_id": "order-1042",
    "metadata": { "cart": "c-88213" }
  }'

The same request from Node.js:

// Node 18 or later, on your server
const res = await fetch("https://candorpayments.com/api/v1/checkout_sessions", {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + process.env.CANDOR_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-" + order.id,
  },
  body: JSON.stringify({
    line_items: order.lines.map((l) => ({ name: l.name, amount: l.cents, quantity: l.quantity })),
    success_url: "https://www.example.com/thanks?checkout={CHECKOUT_SESSION_ID}",
    cancel_url: "https://www.example.com/cart",
    client_reference_id: "order-" + order.id,
  }),
});
const checkout = await res.json();
if (!res.ok) throw new Error(checkout.message);
// send the customer to checkout.url

The answer is the checkout. Its fee is Candor's fee, in cents: here, 3.9% + 30¢ on $68.00 is $2.95.

{
  "id": "cs_live_a1Rk7Qz2Lm9Xc4Vb6Nn8Pp0T",
  "object": "checkout",
  "url": "https://checkout.stripe.com/c/pay/cs_live_a1Rk7Qz2Lm9Xc4Vb6Nn8Pp0T",
  "status": "open",
  "payment_status": "unpaid",
  "amount": 6800,
  "fee": 295,
  "currency": "usd",
  "customer_email": null,
  "client_reference_id": "order-1042",
  "payment": null,
  "payment_link": null,
  "metadata": {
    "cart": "c-88213"
  },
  "expires_at": "2026-10-27T15:04:05.000Z",
  "created": "2026-10-26T15:04:05.000Z"
}

Keys and requests

  • Address: every route starts with https://candorpayments.com/api/v1/.
  • Your key goes in an Authorization header on every request, as Authorization: Bearer cnd_live_... with your whole key. Making a new key on your Candor page turns the old one off at once, and so does turning the API off.
  • Server only: a request from a web page (one with an Origin header) gets a 403 browser error, so a key copied into a page can't be used from it.
  • JSON in and out: a POST with a body sends JSON with Content-Type: application/json, up to 10,000 characters. A POST with nothing to send, like deactivating a link, needs no body.
  • Amounts are whole cents, in US dollars: 3400 is $34.00.
  • Times are in UTC, written like 2026-10-26T15:04:05.000Z.
  • IDs are Stripe's, such as cs_live_..., plink_..., prod_..., in_... and sub_..., because everything you create is on your own Stripe account, and shows on your Candor dashboard too.
  • No test mode: your key works on your real account, so everything you create is real. See what the API doesn't do for how to check your work.

Retries without paying twice

Send an Idempotency-Key header with every POST that creates something, and with invoice reminders. Use a value that's unique to what you're doing, like your order number: 1 to 100 letters, numbers, dots, colons, dashes or underscores. If a request with the same key comes again within 24 hours, you get back what the first one made instead of a second one, so it's safe to retry after a timeout or a 5xx error. A key sent again with different details is turned down with a 400 rejected error.

Lists

Every list comes back newest first, 25 at a time. Ask for 1 to 100 with ?limit=. To get the next page, send next_starting_after back as ?starting_after=, until has_more is false.

curl "https://candorpayments.com/api/v1/invoices?limit=1" \
  -H "Authorization: Bearer $CANDOR_API_KEY"
{
  "object": "list",
  "data": [
    {
      "id": "in_1QbD3eFgHi4JkL5M",
      "object": "invoice",
      "number": "F3A2C1B0-0007",
      "status": "open",
      "overdue": false,
      "customer_email": "pat@example.com",
      "customer_name": "Pat Rivera",
      "amount": 120000,
      "amount_paid": 0,
      "amount_remaining": 120000,
      "fee": 5190,
      "method": "card",
      "due_date": "2026-11-09T15:04:05.000Z",
      "created": "2026-10-26T15:04:05.000Z",
      "url": "https://invoice.stripe.com/i/acct_1QaB2cDeFg3HiJ4K/live_YWNjdF8xUWFCMmNEZUZnM0hpSjRLLF9SYk",
      "pdf": "https://pay.stripe.com/invoice/acct_1QaB2cDeFg3HiJ4K/live_YWNjdF8xUWFCMmNEZUZnM0hpSjRLLF9SYk/pdf",
      "memo": "Thank you for your business.",
      "subscription": null,
      "reminders": []
    }
  ],
  "has_more": true,
  "next_starting_after": "in_1QbD3eFgHi4JkL5M"
}

A page can hold fewer items than you asked for, or even none while has_more is true, because each list leaves out what isn't its kind: a subscription plan's payment link isn't in the list of payment links, for example. Keep going with next_starting_after.

Errors

An error has an HTTP status, a code that stays the same, and a message that says what to change, in words you can show to the person using your software.

{
  "error": "bad_success_url",
  "message": "success_url has to be an https address on the public internet."
}
Error statuses and codes
Status and codeWhat it means
400 bad_request, and the codes that start with bad_, such as bad_amount or bad_line_itemsSomething in the request isn't right. The message says what to change.
400 rejectedStripe turned the request down. The message gives Stripe's reason.
401 api_keyNo key, a key that isn't valid, or one that's been replaced or turned off on your Candor page.
403 browserThe request came from a web page. Call the API from your server.
404 not_foundThere's no such route, or the object isn't on your account.
409 terms_first, not_active, price_missing, email_off, not_open, endedYour account, or the object, can't do that yet: confirm your plan, finish setting up with Stripe, or check the invoice or subscription isn't already closed.
413 too_largeThe body is over 10,000 characters.
415 send_jsonA POST with a body has to send JSON, with Content-Type: application/json.
429 (no JSON body)More than 120 requests in a minute from one IP address. Wait a minute, then try again.
500 errorSomething went wrong on our side. Try again in a minute.
502 stripe_error, emailStripe, or our email service, couldn't do it just then. Try again with the same Idempotency-Key.
503 unavailable, stripe_unreachable, busy, stripe_keyThe API or Stripe isn't available for a moment. Try again in a minute.

Checkouts

A checkout is a Stripe payment page for one order, with up to 20 lines. Customers pay by card, Apple Pay or Google Pay. The price is your card price on the total: 3.9% + 30¢ on Simple. A checkout's page works for 24 hours.

Create a checkout

POST /api/v1/checkout_sessions

line_items required
1 to 20 lines. Each is either {"name", "amount", "quantity"}: what the customer sees (up to 200 characters), the price of one in cents, and how many (1 to 99, 1 if you leave it out); or {"product", "quantity"}: one of your products, at its price. The total has to be from $0.50 to $50,000.
success_url required
Where Stripe sends the customer after paying: an https address on the public internet. Put {CHECKOUT_SESSION_ID} in it, and Stripe swaps in the checkout's ID.
cancel_url
Where the customer goes if they leave without paying. Without one, the checkout has no back button.
customer_email
Fills in the customer's email address.
client_reference_id
Your own ID for the order: up to 200 letters, numbers, spaces, dots, colons, dashes or underscores. It comes back on the checkout and in its webhook.
metadata
Up to 20 keys of your own (1 to 40 letters, numbers, dots, dashes or underscores, not starting with candor_), with text or number values up to 500 characters. They come back as text.
collect_shipping
true asks for a US shipping address. It's on whenever a product in the checkout ships.

The checkout's status is open, complete or expired, and its payment_status is unpaid or paid (or no_payment_required, in the event for someone starting a plan's free trial). Once it's paid, payment is the payment's ID.

Get a checkout

GET /api/v1/checkout_sessions/{id} returns the checkout, as above.

The store checkout, without code

If your website has a cart but no server of its own, it can send customers to your checkout address with what's in the cart. Your address is on your Candor account page, on the Online store tab, with your product IDs: add your products there, or with the API. Customers pay on Stripe's page, and come back to your website afterwards.

https://candorpayments.com/buy/acct_1QaB2cDeFg3HiJ4K?items=prod_RaB2cDeFgH3iJk:2,prod_RaE5fGhIjK6lMn&return=https%3A%2F%2Fwww.example.com%2Fthanks&ref=order-1042

Or, from a form on your website, with the same fields:

<form method="post" action="https://candorpayments.com/buy/acct_1QaB2cDeFg3HiJ4K">
  <input type="hidden" name="items" value="prod_RaB2cDeFgH3iJk:2,prod_RaE5fGhIjK6lMn:1">
  <input type="hidden" name="return" value="https://www.example.com/thanks">
  <input type="hidden" name="ref" value="order-1042">
  <button type="submit">Check out</button>
</form>
items required
Product IDs, separated by commas, each with : and how many if it's more than one: prod_A:2,prod_B. Up to 20 products, 1 to 99 of each.
return
A page on your website (the one on your Candor page, or one of its subdomains) to send customers back to. Anywhere else, or left out, and they go back to your website's address.
email
Fills in the customer's email address.
ref
Your order ID: up to 200 letters, numbers, spaces, dots, colons, dashes or underscores. It's the checkout's client_reference_id.

After paying, customers come back with candor_session= and the checkout's ID added to the address. Check that it's paid with GET /api/v1/checkout_sessions/{id}, or the checkout.completed webhook, before you ship. If you haven't given us your website, customers who pay see Candor's payment received page.

A link for one amount, to send by email, text or chat, or to put behind a button. By card it costs 3.9% + 30¢ on Simple; by bank, 1% + Stripe's bank fee (0.8%, never more than $5, plus $1.50).

Create a payment link

POST /api/v1/payment_links

curl https://candorpayments.com/api/v1/payment_links \
  -H "Authorization: Bearer $CANDOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: deposit-henley" \
  -d '{ "amount": 25000, "description": "Deposit for the Henley wedding order", "method": "card" }'
amount required
In cents: $0.50 to $50,000 by card, or $1.54 to $50,000 by bank, because of Stripe's $1.50 bank check.
description required
What the payment is for, 2 to 120 characters. Your customer sees it on the payment page.
method required
card (card, Apple Pay or Google Pay) or bank (a US bank account, by ACH).
single_use
true (if you leave it out) turns the link off after one payment. false keeps it on, for a pay button.
{
  "id": "plink_1QbF5gHiJk6LmN7O",
  "object": "payment_link",
  "url": "https://buy.stripe.com/7sY14m3fG9aB2cD000",
  "active": true,
  "amount": 25000,
  "fee": 1005,
  "description": "Deposit for the Henley wedding order",
  "method": "card",
  "paid": 0,
  "limit": 1
}

On a single-use link, paid counts its payments and limit is 1; on a link that stays on, both are null.

GET /api/v1/payment_links lists your links, and POST /api/v1/payment_links/{id}/deactivate turns one off.

Products

The things your store sells, each with one price, for the store checkout and for checkouts you create.

POST /api/v1/products adds one. Confirm your plan on your Candor page first.

curl https://candorpayments.com/api/v1/products \
  -H "Authorization: Bearer $CANDOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: product-linen-large" \
  -d '{ "name": "Linen candle, large", "amount": 3400, "shipping": true }'
name required
What customers see, 2 to 100 characters.
amount required
The price of one, in cents: $0.50 to $50,000.
shipping
true if it ships: a checkout with it asks for a US shipping address.
{
  "id": "prod_RaB2cDeFgH3iJk",
  "object": "product",
  "name": "Linen candle, large",
  "amount": 3400,
  "shipping": true,
  "active": true,
  "created": "2026-10-26T15:04:05.000Z"
}

GET /api/v1/products lists the products on sale, and POST /api/v1/products/{id}/archive takes one off sale.

Invoices

An itemized invoice, emailed to your customer with a pay button and a PDF, paid online by card or from their bank account. We remind them 3 days before it's due, on the day, and 3 and 7 days after, until it's paid. By card it costs 4.3% + 30¢ on Simple, which includes Stripe's 0.4% invoicing fee at cost; by bank, 1.4% + Stripe's bank fee (0.8%, never more than $5, plus $1.50).

Create and send an invoice

POST /api/v1/invoices

curl https://candorpayments.com/api/v1/invoices \
  -H "Authorization: Bearer $CANDOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-2026-117" \
  -d '{
    "customer_email": "pat@example.com",
    "customer_name": "Pat Rivera",
    "items": [{ "description": "Website design, October", "quantity": 1, "amount": 120000 }],
    "days_until_due": 14,
    "memo": "Thank you for your business."
  }'
customer_email required
Where the invoice goes.
customer_name
Shown on the invoice, up to 100 characters.
items required
1 to 25 lines, each {"description", "quantity", "amount"}: 2 to 200 characters, 1 to 999 (1 if you leave it out), and the price of one in cents. The total has to be from $1 to $50,000 ($1.54 by bank).
method
card (if you leave it out) or bank.
days_until_due
1 to 90 days after it's sent. 14 if you leave it out.
memo
A note to your customer on the invoice, up to 500 characters.
{
  "id": "in_1QbD3eFgHi4JkL5M",
  "object": "invoice",
  "number": "F3A2C1B0-0007",
  "status": "open",
  "overdue": false,
  "customer_email": "pat@example.com",
  "customer_name": "Pat Rivera",
  "amount": 120000,
  "amount_paid": 0,
  "amount_remaining": 120000,
  "fee": 5190,
  "method": "card",
  "due_date": "2026-11-09T15:04:05.000Z",
  "created": "2026-10-26T15:04:05.000Z",
  "url": "https://invoice.stripe.com/i/acct_1QaB2cDeFg3HiJ4K/live_YWNjdF8xUWFCMmNEZUZnM0hpSjRLLF9SYk",
  "pdf": "https://pay.stripe.com/invoice/acct_1QaB2cDeFg3HiJ4K/live_YWNjdF8xUWFCMmNEZUZnM0hpSjRLLF9SYk/pdf",
  "memo": "Thank you for your business.",
  "subscription": null,
  "reminders": [],
  "sent": true
}

sent is false if Stripe couldn't email it just then: send it again with the reminder route below. status is open until it's paid, or void; overdue turns true once the due date passes, and reminders lists the reminders sent so far, in days from the due date.

  • GET /api/v1/invoices lists your invoices, and GET /api/v1/invoices/{id} gets one.
  • POST /api/v1/invoices/{id}/remind emails an unpaid invoice to your customer again.
  • POST /api/v1/invoices/{id}/void cancels an unpaid invoice, so it can't be paid.

Plans and subscriptions

A plan is a monthly or yearly price with a page customers subscribe on, by card, Apple Pay or Google Pay, with an optional free trial. Each payment costs 4.6% + 30¢ on Simple, which includes Stripe's 0.7% billing fee at cost. We email subscribers before a trial ends and before each yearly renewal, try again when a renewal payment fails, and give them a page to update their card or cancel.

Create a plan

POST /api/v1/plans

curl https://candorpayments.com/api/v1/plans \
  -H "Authorization: Bearer $CANDOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: plan-candle-box" \
  -d '{ "name": "Monthly candle box", "amount": 2900, "interval": "month", "trial_days": 14 }'
name required
What subscribers see, 2 to 80 characters.
amount required
Each payment, in cents: $1 to $10,000.
interval required
month or year.
trial_days
A free trial of 7, 14 or 30 days, or 0 (if you leave it out) for none.
{
  "id": "plink_1QbE4fGhIj5KlM6N",
  "object": "plan",
  "url": "https://buy.stripe.com/aEU5kD0Xy9Zb1tK001",
  "active": true,
  "name": "Monthly candle box",
  "amount": 2900,
  "interval": "month",
  "trial_days": 14,
  "fee": 163,
  "product": "prod_RaC3dEfGhI4jKl"
}

Send customers to the plan's url to subscribe. Afterwards they see the page where they can update their card or cancel; its address is manage_url in your account, for your emails and your website. GET /api/v1/plans lists your plans, and POST /api/v1/plans/{id}/deactivate stops new subscriptions to one; people already subscribed carry on.

Subscriptions

GET /api/v1/subscriptions lists them, and GET /api/v1/subscriptions/{id} gets one. Its status is Stripe's, such as trialing, active, past_due while a failed payment is tried again, or canceled.

{
  "id": "sub_1QbG6hIjKl7MnO8P",
  "object": "subscription",
  "status": "trialing",
  "plan_name": "Monthly candle box",
  "customer": "cus_RaD4eFgHiJ5kLm",
  "customer_email": "sam@example.com",
  "customer_name": "Sam Okafor",
  "amount": 2900,
  "interval": "month",
  "current_period_end": "2026-11-09T15:04:05.000Z",
  "trial_end": "2026-11-09T15:04:05.000Z",
  "cancel_at_period_end": false,
  "canceled_at": null,
  "created": "2026-10-26T15:04:05.000Z"
}

POST /api/v1/subscriptions/{id}/cancel ends one when its paid period ends, so the subscriber keeps what they've paid for. Send {"at_period_end": false} to end it now, with no refund: refund it from your Candor dashboard if you want to.

curl -X POST https://candorpayments.com/api/v1/subscriptions/sub_1QbG6hIjKl7MnO8P/cancel \
  -H "Authorization: Bearer $CANDOR_API_KEY"

Your account and fees

GET /api/v1/account gives your business's prices, what it can take, your store checkout's address and your subscribers' page. state is active once Stripe has verified your business; setup, review or attention while it needs something; or rejected or closed.

{
  "object": "account",
  "id": "acct_1QaB2cDeFg3HiJ4K",
  "name": "Fernway Candle Co.",
  "mode": "live",
  "state": "active",
  "prices": {
    "card": "3.9% + 30¢",
    "bank": "1% + Stripe's bank fee (0.8%, never more than $5, plus $1.50)",
    "invoice": "4.3% + 30¢",
    "invoice_bank": "1.4% + Stripe's bank fee (0.8%, never more than $5, plus $1.50)",
    "subscription": "4.6% + 30¢"
  },
  "can": {
    "card": true,
    "bank": true,
    "subscriptions": true
  },
  "buy_url": "https://candorpayments.com/buy/acct_1QaB2cDeFg3HiJ4K",
  "manage_url": "https://billing.stripe.com/p/login/7sI5mA0Kd2Hg9Qx000"
}

can.subscriptions is true once you can take card payments and Candor's customer emails are switched on, as every trial and renewal gets its reminder. manage_url is null until you make your first plan.

GET /api/v1/fees?amount=&method=&kind= works out Candor's fee on one payment, in cents. method is card or bank; kind is payment (a checkout or a link), invoice or subscription.

curl "https://candorpayments.com/api/v1/fees?amount=10000&method=card&kind=payment" \
  -H "Authorization: Bearer $CANDOR_API_KEY"
{
  "object": "fee",
  "amount": 10000,
  "method": "card",
  "kind": "payment",
  "fee": 420,
  "net": 9580,
  "rate": "3.9% + 30¢"
}

Webhooks

Add your server's address on the API and webhooks tab of your Candor page: an https address on port 443, on the public internet. We send it each of the events below as it happens, as a POST with a JSON body, signed with your signing secret, which is on the same tab. Send a test event from there to check your code.

The events Candor sends, and the object in each one's data.object
EventWhen
checkout.completedA customer finishes a checkout: one you made, the store checkout, or a payment link's or a plan's page. Object: checkout.
payment.succeededA payment goes through: a checkout, a link, an invoice or a subscription. Object: payment.
payment.failedA payment attempt fails. Object: payment.
payment.refundedYou refund a payment, in full or in part. Object: refund.
dispute.createdA customer disputes a payment with their bank. Object: dispute.
dispute.closedA dispute is won, lost or withdrawn. Object: dispute.
invoice.paidAn invoice is paid, one-off or a subscription's. Object: invoice.
invoice.payment_failedA payment on an invoice fails. Object: invoice.
invoice.voidedAn invoice is voided. Object: invoice.
subscription.createdSomeone subscribes to a plan. Object: subscription.
subscription.updatedA subscription changes: it renews, a trial ends, a payment fails, or it's set to cancel. Object: subscription.
subscription.canceledA subscription ends. Object: subscription.
subscription.trial_will_endThree days before a free trial ends. Object: subscription.
payout.paidA payout is due in your bank account. Object: payout.
payout.failedA payout fails. Object: payout.
pingYou send a test event from your Candor page. Object: ping.
{
  "id": "evt_1QbH7jKlMn9OpQ0R",
  "object": "event",
  "type": "checkout.completed",
  "api_version": "2026-10-26",
  "created": "2026-10-26T15:09:12.000Z",
  "livemode": true,
  "account": "acct_1QaB2cDeFg3HiJ4K",
  "data": {
    "object": {
      "id": "cs_live_a1Rk7Qz2Lm9Xc4Vb6Nn8Pp0T",
      "object": "checkout",
      "url": null,
      "status": "complete",
      "payment_status": "paid",
      "amount": 6800,
      "fee": 295,
      "currency": "usd",
      "customer_email": "pat@example.com",
      "client_reference_id": "order-1042",
      "payment": "pi_3QbH7iJkLm8NoP9Q",
      "payment_link": null,
      "metadata": {
        "cart": "c-88213"
      },
      "expires_at": "2026-10-27T15:04:05.000Z",
      "created": "2026-10-26T15:04:05.000Z"
    }
  }
}

The other objects look like this. In a subscription's events, customer_email and customer_name are null: get the subscription for them.

{
  "id": "pi_3QbH7iJkLm8NoP9Q",
  "object": "payment",
  "amount": 6800,
  "fee": 295,
  "net": 6505,
  "currency": "usd",
  "status": "succeeded",
  "description": "Order from Fernway Candle Co.",
  "metadata": {},
  "created": "2026-10-26T15:04:05.000Z"
}
{
  "id": "ch_3QbH7iJkLm8NoP9Q",
  "object": "refund",
  "payment": "pi_3QbH7iJkLm8NoP9Q",
  "amount": 6800,
  "amount_refunded": 3400,
  "currency": "usd",
  "refunded": false,
  "created": "2026-10-26T15:04:05.000Z"
}
{
  "id": "du_1QbI8jKlMn0OpQ1R",
  "object": "dispute",
  "payment": "pi_3QbH7iJkLm8NoP9Q",
  "amount": 6800,
  "currency": "usd",
  "reason": "product_not_received",
  "status": "needs_response",
  "evidence_due_by": "2026-11-12T23:59:59.000Z",
  "created": "2026-10-26T15:04:05.000Z"
}
{
  "id": "po_1QbJ9kLmNo1PqR2S",
  "object": "payout",
  "amount": 184250,
  "currency": "usd",
  "status": "paid",
  "arrival_date": "2026-10-28T00:00:00.000Z",
  "failure_code": null,
  "created": "2026-10-26T15:04:05.000Z"
}

Answering, and what happens if your server doesn't

  • Answer with any 2xx status within 4 seconds. If the work takes longer, answer first and do it afterwards.
  • If your server can't be reached, or answers with a redirect, a 408, a 429 or a 5xx, we try once more straight away. Redirects aren't followed, so give the final address.
  • If an event still doesn't get through, or gets no answer in 4 seconds, we send it again every hour, with every event after it, oldest first, for 3 days from that first event. Your Candor page shows when deliveries started failing.
  • An event can arrive more than once, and out of order. Skip an event id you've already handled, and use created to put events in order.
  • Every event is also in GET /api/v1/events for 30 days, in the same shape, newest first.

Checking the signature

Each event has a Candor-Signature header like t=1793027353,v1=5f8a...: the time it was sent, in Unix seconds, and an HMAC-SHA256, in hex, of the time, a dot and the raw body, with your signing secret as the key. Work out the same HMAC, compare the two in constant time, and turn the event down if the time is more than 5 minutes from now.

import crypto from "node:crypto";

// rawBody: the request body exactly as it arrived (a string or a Buffer), before any JSON parsing.
// header: the Candor-Signature header. secret: your signing secret (whsec_...), from your Candor page.
export function verifyCandorSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = {};
  for (const item of String(header || "").split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest();
  const given = Buffer.from(parts.v1 || "", "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
import hashlib, hmac, time

# raw_body: the request body exactly as it arrived (bytes), before any JSON parsing.
# header: the Candor-Signature header. secret: your signing secret (whsec_...), from your Candor page.
def verify_candor_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(item.strip().split("=", 1) for item in (header or "").split(",") if "=" in item)
    try:
        t = int(parts.get("t", ""))
    except ValueError:
        return False
    if abs(time.time() - t) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

With Express, read the raw body for that route:

import express from "express";
import { verifyCandorSignature } from "./candor-signature.js";

const app = express();
// the raw body, not parsed JSON: the signature covers the exact bytes Candor sent
app.post("/candor-events", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyCandorSignature(req.body, req.get("Candor-Signature"), process.env.CANDOR_SIGNING_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(200);            // answer first, then do the work
  handleEvent(event);             // skip event.id if you've seen it before
});

It's the same scheme as Stripe's signatures, so Stripe's own libraries can check it too: in Node, stripe.webhooks.constructEvent(rawBody, candorSignatureHeader, candorSigningSecret). Making a new signing secret on your Candor page replaces the old one at once.

Limits

Limits on the API
WhatLimit
Requests120 a minute from one IP address
A request's body10,000 characters
A checkout1 to 20 lines, 1 to 99 of each; $0.50 to $50,000 in all
A payment link$0.50 to $50,000 by card; $1.54 to $50,000 by bank
A product$0.50 to $50,000
An invoice1 to 25 lines; $1 to $50,000 by card, $1.54 to $50,000 by bank; due 1 to 90 days after it's sent
A plan$1 to $10,000 a month or a year; free trials of 7, 14 or 30 days
Metadata on a checkout20 keys
A page of a list1 to 100
Events in the listThe last 30 days

On a custom card price, a very small amount can be turned down if the fee would be as large as the payment.

What the API doesn't do

  • A test mode. Your key works on your real account, so what you create is real. You can open a checkout's page to check it without paying, look at past events in GET /api/v1/events, and send a test event to your webhook from your Candor page. Don't pay a checkout with your own card to test it: Stripe's rules don't allow testing with real cards.
  • Refunds and disputes. They're on your Candor dashboard, and the payment.refunded and dispute events tell your system.
  • Charging a saved card. Customers pay on Stripe's pages each time; a subscription is the way to charge again.
  • Sales tax, coupons and plan changes. Checkouts and invoices don't add sales tax, plans have no coupons, and a subscriber can't switch plans: cancel and subscribe to the new plan.
  • Other currencies. Charges are in US dollars.

Common questions

Does the API cost extra?

No. A checkout or a payment link made with the API costs the same as one made on your Candor page: 3.9% + 30¢ by card on Simple. Invoices cost 4.3% + 30¢ by card and subscriptions 4.6% + 30¢, which include Stripe's own fee. The API and webhooks cost nothing.

Do card numbers ever reach my server?

No. Customers type their card details on Stripe's pages, so your server only sees the checkout and the payment, never the card. That usually keeps you on the shortest PCI self-assessment questionnaire, SAQ A.

What if my API key leaks?

Make a new key on your Candor page, on the API and webhooks tab. The old key stops working straight away. If you don't use the API any more, turn it off there.

How do I know a payment went through?

From a webhook (checkout.completed, invoice.paid or payment.succeeded) or by getting the checkout or invoice from the API. The customer reaching your thank-you page doesn't prove they paid.

Can software for many businesses use it?

Yes. Each business has its own Candor account and its own API key, which works only on that account. Your software keeps each business's key and calls the API with it.

Build on Stripe, with every fee published.

Open a Candor account in a few minutes. Once Stripe has verified you, your API key is one click away on your Candor page.