Selling access
Sell access to an invite-only script, in ChartDeck with Stripe or on your own site with the vendor API, access codes and signed webhooks.
An invite-only script can be something people pay for. You give each buyer access for a term (a month, a year, for life, and so on), and ChartDeck lets them in and out at the right times. There are two ways to sell. You can use either, or neither, for each script.
| A: Sell in ChartDeck | B: Sell on your own site | |
|---|---|---|
| Who takes the money | Your own Stripe account, connected to ChartDeck | Your shop, course platform or membership site |
| Who lets the buyer in | ChartDeck, when the payment succeeds | Your backend, through the vendor API, or the buyer with an access code |
| Renewals and refunds | Follow the Stripe subscription | Your backend extends or removes access |
| On the script's card | Buy with your prices | Get access, linking to a page on your verified site |
Both options only work on invite-only scripts. A public script can be added by anyone, so there is nothing to sell.
#Terms
Every way of giving access (your Access panel, an invite link, the API, an access code, a purchase) uses the same terms:
| Term | Access ends |
|---|---|
1m | One calendar month later (UTC). 31 January plus one month is 28 or 29 February. |
3m | Three calendar months later. |
1y | One year later. |
lifetime | Never. |
custom | On an exact date you give (expires_at), or after a number of days. Up to 3650 days ahead. |
trial | After days (1 to 30, default 7). Each member gets one trial per script, ever. |
What happens when you give access again:
- Grant starts the term now, but never shortens access that is still running. A member with lifetime access keeps it; a member whose access runs longer than the new term keeps the later end.
- Extend adds the term on top of the current end (or starts now if their access has ended). Lifetime stays lifetime.
- Set puts the end exactly where you say, even if that is sooner. Setting it to nothing means lifetime.
When a term ends, the script stays on the member's charts but stops running and says "Access expired". Giving them access again brings it straight back. Removing someone's access (revoking it) says "Access removed by the author".
#Option A: sell in ChartDeck
- Open Account → Selling & access, choose your country and connect your Stripe account. Stripe asks for the details it needs to pay you. ChartDeck never sees your bank details.
- On the script, add one or more prices: monthly, yearly and/or lifetime (one payment), in your currency.
- Set the script's selling mode to Sell in ChartDeck. You can offer new buyers a free trial of 1 to 30 days.
- In your own Stripe Dashboard, save a Customer portal setup (Settings → Billing → Customer portal), so buyers can manage or cancel their subscription.
Buyers see Get access with your prices on the script's card. Payment runs on your Stripe account (you are the seller): the money, receipts, taxes, refunds and disputes are yours, in your Stripe Dashboard. ChartDeck takes no fee: you keep the whole sale (only Stripe's own processing fees apply, on your Stripe account).
- A subscription keeps access running while it renews: access lasts to the end of the paid period plus 3 days for payment retries.
- A canceled subscription ends access at the end of the paid period ("Access expired").
- A full refund or an opened dispute removes access at once. A won dispute gives it back. A partial refund changes nothing.
- Buyers see their purchases, renewal dates and a Manage button under Account → My subscriptions.
#Option B: sell on your own site
You keep selling where you already sell, and tell ChartDeck who paid. There are two ways to let a buyer in:
- By @handle or ChartDeck ID. If your checkout asks for the buyer's ChartDeck @handle (or their ChartDeck ID, shown under Account → My subscriptions), your backend grants access with the API straight away.
- With an access code. If you only have the buyer's email, create a single-use code and email it to them yourself. They redeem it in ChartDeck. You never need to know who they are in ChartDeck.
#Privacy: no emails
ChartDeck never tells anyone whether an email address has an account. The API refuses emails (422 email_not_supported) and never returns one. Members are always { "id": "<ChartDeck ID>", "handle": "<handle or null>" }. The handle is only shown when the member's profile is public. Your private note on a member is never shown to them.
#API keys
Create a key under Account → Selling & access → API keys. The key looks like cdv_ followed by 43 characters and is shown once: copy it into your server's secret store. ChartDeck keeps only a fingerprint of it, so a lost key cannot be shown again. Make a new one instead.
- Up to 10 active keys. A key can be limited to some of your scripts; otherwise it manages all your invite-only scripts.
- Rotate makes a new key with the same name and scope. The old key stops working at once, so put the new key in place straight after rotating.
- Revoke stops a key for good.
Send the key in the Authorization header of every call. Keep it on your server: never put it in a web page or an app. The API ignores ChartDeck sign-in cookies, so being signed in to ChartDeck in the same browser grants nothing.
export CD_KEY="cdv_your_key_here"
curl https://chartdeck.trade/api/v1/vendor/me -H "Authorization: Bearer $CD_KEY"{ "author": { "id": "6f1c...", "handle": "yourhandle" }, "key": { "id": "...", "name": "My shop", "prefix": "cdv_Ab3dE9fG", "scripts": null } }The full description of the API, for code generators and API tools, is the OpenAPI document at https://chartdeck.trade/api/v1/vendor/openapi.json (no key needed).
#Listings
A listing id is your script's public id (the 26 characters in its Community link).
curl https://chartdeck.trade/api/v1/vendor/listings -H "Authorization: Bearer $CD_KEY"{ "listings": [ { "id": "01j9x0abcdefghjkmnpqrstvwx", "name": "Ribbon", "published": true, "listed": false, "members": 42 } ] }Your key only reaches your own invite-only scripts. Another author's script is 404 not_found (the API never says whether it exists), one of yours outside the key's scope is 403 forbidden_listing, and one of yours with a public audience is 409 not_invite_only.
#Grant access
curl -X POST https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/members \
-H "Authorization: Bearer $CD_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001" \
-d '{ "user": "@buyerhandle", "term": "1m", "note": "order 1001" }'user is "@handle" or a ChartDeck ID. The answer is 201 when the member was let in, 200 when existing access was extended or did not change:
{
"result": "granted",
"member": {
"user": { "id": "3b2a...", "handle": "buyerhandle" }, "listing": "01j9x0abcdefghjkmnpqrstvwx",
"source": "api", "term": "1m", "trial": false, "status": "active",
"expiresAt": "2026-11-09T12:00:00.000Z", "since": "2026-10-09T12:00:00.000Z",
"updatedAt": "2026-10-09T12:00:00.000Z", "revokedAt": null, "note": "order 1001"
}
}A custom end and a trial:
-d '{ "user": "@buyerhandle", "term": "custom", "expires_at": "2027-03-31T23:59:59Z" }'
-d '{ "user": "@buyerhandle", "term": "trial", "days": 7 }'A script can have up to 1000 members with live access. Granting to a new member past that is 409 members_limit.
#Renew, change and remove
# a renewal: add a month on top of the current end
curl -X POST https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/members/%40buyerhandle/extend \
-H "Authorization: Bearer $CD_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: invoice-2002" \
-d '{ "term": "1m" }'
# set an exact end (null = lifetime) and/or your note
curl -X PATCH https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/members/%40buyerhandle \
-H "Authorization: Bearer $CD_KEY" -H "Content-Type: application/json" \
-d '{ "expires_at": "2026-12-31T23:59:59Z", "note": "upgraded" }'
# remove access (a refund, a cancellation); calling it again changes nothing
curl -X DELETE https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/members/%40buyerhandle \
-H "Authorization: Bearer $CD_KEY"In a URL path, write the @ as %40, or use the member's ChartDeck ID.
#Look up members
curl "https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/members?status=active&limit=100" \
-H "Authorization: Bearer $CD_KEY"
curl https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/members/%40buyerhandle \
-H "Authorization: Bearer $CD_KEY"status is active, expired, revoked or all (the default). limit is 1 to 200 (default 50). When there are more, the answer has a next value: pass it back as cursor for the next page. Asking about someone who was never a member is 404 member_not_found.
#Access codes
A code is single use and looks like CDA-7K3M-Q9TX-2HV4-PR8N. The access term starts when the buyer redeems it, not when you create it. The code itself must be redeemed within code_valid_days (1 to 365, default 30).
curl -X POST https://chartdeck.trade/api/v1/vendor/listings/01j9x0abcdefghjkmnpqrstvwx/codes \
-H "Authorization: Bearer $CD_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: order-1003" \
-d '{ "term": "1y", "external_ref": "order-1003", "quantity": 1 }'{ "codes": [ { "id": "9d0e...", "code": "CDA-7K3M-Q9TX-2HV4-PR8N", "url": "https://chartdeck.trade/redeem?code=CDA-7K3M-Q9TX-2HV4-PR8N",
"term": "1y", "days": null, "until": null, "expiresAt": "2026-11-08T12:00:00.000Z", "externalRef": "order-1003" } ] }- The code is returned only here: ChartDeck keeps just a fingerprint and the last four characters. Send the code or its
urlto your buyer. - The buyer opens the link (signing in or signing up first) or types the code in ChartDeck. Capitals, spaces and dashes do not matter, and O / I / L are read as 0 / 1 / 1.
external_refis your own order id. It comes back in the webhook when the code is redeemed, so you can match the two.quantitymakes up to 100 codes at once.- A code does not let back in a member you removed (with
DELETE .../members/<user>or Remove / Block in the Access panel): they get403 access_removedand the code stays unused. To let them in again, grant them access yourself (POST .../members, or the Access panel). A member who lost access through a refund or a dispute can come back with a new code. - List codes with
GET .../codes?status=unused|redeemed|revoked|expired|all(never with the code itself; a redeemed one shows who redeemed it). Turn off an unused code withDELETE /api/v1/vendor/codes/<id>. A redeemed code cannot be turned off (409 already_redeemed); remove the member's access instead.
You can also make codes yourself, without the API, in the script's Access panel.
#The access log
Every change to your members (from the API, codes, your Access panel, purchases and expiries) is in the log, oldest first:
curl "https://chartdeck.trade/api/v1/vendor/events?after=0&limit=100" -H "Authorization: Bearer $CD_KEY"{ "events": [ { "id": 812, "type": "access.granted", "listing": "01j9x0abcdefghjkmnpqrstvwx", "user": { "id": "3b2a...", "handle": "buyerhandle" },
"source": "api", "actor": "api", "term": "1m", "expiresAt": "2026-11-09T12:00:00.000Z", "prevExpiresAt": null, "ref": null,
"at": "2026-10-09T12:00:00.000Z" } ], "next": 812 }Pass next as after to read on. The types are access.granted, access.extended, access.revoked, access.expired and access.requested (a member asked for access to a listed script).
#Idempotency
Every POST, PATCH and DELETE accepts an Idempotency-Key header: any unique text up to 255 characters, such as your order or invoice id. If your request times out, send it again with the same key. ChartDeck keeps the first answer for 24 hours and sends it back with the header Idempotent-Replayed: true, so a retry never grants twice. The same key with a different request is 422 idempotency_mismatch; a retry while the first is still running is 409 idempotency_in_progress. Keys are per API key.
#Rate limits
Each API key can make 120 reads and 60 changes a minute. Over that, the answer is 429 rate_limited with a Retry-After header (seconds). Wait that long and retry.
#Errors
Errors are JSON with a status code:
{ "error": { "code": "email_not_supported", "message": "Use the member’s @handle or ChartDeck ID, or create an access code and send it to your buyer." } }| Status | Codes |
|---|---|
| 401 | unauthorized: no key, a wrong key or a revoked key |
| 403 | forbidden_listing: the key is limited to other scripts |
| 404 | not_found, user_not_found, member_not_found, code_not_found, no_endpoint |
| 409 | self (you always have access to your own script), members_limit, trial_used, not_invite_only, already_redeemed, endpoint_disabled, idempotency_in_progress |
| 422 | invalid, invalid_term, email_not_supported, idempotency_mismatch |
| 429 | rate_limited |
#Webhooks
ChartDeck can tell your server whenever access changes. Under Account → Selling & access → Webhook enter an https:// address on your server and choose the events. The signing secret (whsec_cd_ and 43 characters) is shown once; Roll secret makes a new one.
Each event is a POST with a JSON body:
{
"id": "evt_0h5x2k9q7m3t1v8c4b6n0r2w5y",
"type": "access.granted",
"created": 1791633600,
"data": {
"listing": "01j9x0abcdefghjkmnpqrstvwx", "user": { "id": "3b2a...", "handle": "buyerhandle" },
"source": "api", "actor": "member", "term": "1y", "expires_at": "2027-10-09T12:00:00.000Z", "previous_expires_at": null,
"ref": "9d0e...", "code": "9d0e...", "external_ref": "order-1003"
}
}- Answer with any
2xxstatus, quickly. Anything else (a redirect too) counts as a failure and is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours; after 8 tries it is given up. - The
idis the same on every retry, and when you press Resend on a delivery. Use it to ignore duplicates. - If every delivery fails for 3 days, ChartDeck turns the webhook off and tells you in your notifications. Fix your server and save the webhook again: waiting events then go out.
POST /api/v1/vendor/webhook/testsends anaccess.testevent so you can check your endpoint.- Every request carries
ChartDeck-Event-Id,ChartDeck-Event-TypeandChartDeck-Signature.
Check the signature before trusting a webhook. ChartDeck-Signature is t=<unix time>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> with your secret. Use the raw body exactly as it arrived, before any JSON parsing. A Node.js example:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyChartDeckSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header || !secret) return false;
let t = NaN;
const sigs = [];
for (const part of header.split(',')) {
const [k, v] = part.split('=');
if (k === 't') t = Number(v);
else if (k === 'v1') sigs.push(v);
}
if (!Number.isFinite(t) || !sigs.length) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; // too old: a replay
const want = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
return sigs.some((s) => { const got = Buffer.from(s); return got.length === want.length && timingSafeEqual(got, want); });
}
// Express: keep the raw body for this route
app.post('/chartdeck/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyChartDeckSignature(req.body.toString('utf8'), req.get('ChartDeck-Signature'), process.env.CHARTDECK_WEBHOOK_SECRET)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// ignore event.id if you have seen it before, then act on event.type
res.status(200).end();
});#The "Get access" link and your verified site
With option B, the script's card can show one Get access button that opens a page on your site. To keep members safe it may only point to a site you have proved is yours.
- Under Account → Selling & access → Your site enter your domain, for example
myshop.com. ChartDeck shows a token. - Prove you control the domain in one of two ways:
- DNS: add a TXT record named
_chartdeck-verify.myshop.comwith the valuechartdeck-verify=<token>. - File: put a text file at
https://myshop.com/.well-known/chartdeck-verify.txtthat contains just the token. It must be served over https; redirects are followed only within your domain.
- DNS: add a TXT record named
- Press Verify. DNS changes can take a while to appear; you can try 10 times an hour.
- In the script's selling options choose Sell on my site and paste the page address. It must start with
https://and be on your domain or one of its subdomains (shop.myshop.comis fine).
Changing the domain needs a new verification, and the button is hidden until it is done. The selling options also have an optional free-trial length you can mention on your page.
The button only takes buyers to your page. Letting them in is still done by your backend (a grant or a code).