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
- 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.
- 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. - Confirm the payment on your server, with the
checkout.completedwebhook, or by getting the checkout and checking thatpayment_statusispaid. 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.urlThe 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
browsererror, 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:
3400is $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_...andsub_..., 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."
}| Status and code | What it means |
|---|---|
400 bad_request, and the codes that start with bad_, such as bad_amount or bad_line_items | Something in the request isn't right. The message says what to change. |
400 rejected | Stripe turned the request down. The message gives Stripe's reason. |
401 api_key | No key, a key that isn't valid, or one that's been replaced or turned off on your Candor page. |
403 browser | The request came from a web page. Call the API from your server. |
404 not_found | There's no such route, or the object isn't on your account. |
409 terms_first, not_active, price_missing, email_off, not_open, ended | Your 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_large | The body is over 10,000 characters. |
415 send_json | A 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 error | Something went wrong on our side. Try again in a minute. |
502 stripe_error, email | Stripe, or our email service, couldn't do it just then. Try again with the same Idempotency-Key. |
503 unavailable, stripe_unreachable, busy, stripe_key | The 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_itemsrequired- 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_urlrequired- 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_shippingtrueasks 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-1042Or, 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>itemsrequired- 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.
Payment links
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" }'amountrequired- 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.
descriptionrequired- What the payment is for, 2 to 120 characters. Your customer sees it on the payment page.
methodrequiredcard(card, Apple Pay or Google Pay) orbank(a US bank account, by ACH).single_usetrue(if you leave it out) turns the link off after one payment.falsekeeps 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 }'namerequired- What customers see, 2 to 100 characters.
amountrequired- The price of one, in cents: $0.50 to $50,000.
shippingtrueif 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_emailrequired- Where the invoice goes.
customer_name- Shown on the invoice, up to 100 characters.
itemsrequired- 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). methodcard(if you leave it out) orbank.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/invoiceslists your invoices, andGET /api/v1/invoices/{id}gets one.POST /api/v1/invoices/{id}/remindemails an unpaid invoice to your customer again.POST /api/v1/invoices/{id}/voidcancels 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 }'namerequired- What subscribers see, 2 to 80 characters.
amountrequired- Each payment, in cents: $1 to $10,000.
intervalrequiredmonthoryear.trial_days- A free trial of
7,14or30days, or0(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.
| Event | When |
|---|---|
checkout. | A customer finishes a checkout: one you made, the store checkout, or a payment link's or a plan's page. Object: checkout. |
payment. | A payment goes through: a checkout, a link, an invoice or a subscription. Object: payment. |
payment. | A payment attempt fails. Object: payment. |
payment. | You refund a payment, in full or in part. Object: refund. |
dispute. | A customer disputes a payment with their bank. Object: dispute. |
dispute. | A dispute is won, lost or withdrawn. Object: dispute. |
invoice. | An invoice is paid, one-off or a subscription's. Object: invoice. |
invoice. | A payment on an invoice fails. Object: invoice. |
invoice. | An invoice is voided. Object: invoice. |
subscription. | Someone subscribes to a plan. Object: subscription. |
subscription. | A subscription changes: it renews, a trial ends, a payment fails, or it's set to cancel. Object: subscription. |
subscription. | A subscription ends. Object: subscription. |
subscription. | Three days before a free trial ends. Object: subscription. |
payout. | A payout is due in your bank account. Object: payout. |
payout. | A payout fails. Object: payout. |
ping | You 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
idyou've already handled, and usecreatedto put events in order. - Every event is also in
GET /api/v1/eventsfor 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
| What | Limit |
|---|---|
| Requests | 120 a minute from one IP address |
| A request's body | 10,000 characters |
| A checkout | 1 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 invoice | 1 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 checkout | 20 keys |
| A page of a list | 1 to 100 |
| Events in the list | The 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.refundedand 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.