Skip to main content

Unofficial command-line interface for the Cardly physical card sending API

Project description

cardly-cli

Unofficial command-line interface for the Cardly API v2 — send real, physical cards from your terminal.

Not affiliated with Cardly. MIT licensed.

Install

uv tool install cardly-cli

Configure

The API key is resolved in this order: --api-key > CARDLY_API_KEY > a profile in ~/.config/cardly/config.toml.

cardly configure set prod --api-key live_xxx --default
cardly configure set sandbox --api-key test_xxx

Rather than storing the key on disk, point a profile at any command that prints it on stdout:

cardly configure set prod --api-key-cmd 'your-secret-tool read cardly/api-key'

cardly configure list never prints stored keys — it shows an array of row objects ([{name, base_url, has_key, default}]), one per profile.

Verify with a free call that spends no credit:

cardly echo
cardly account balance

Test mode

Keys prefixed test_ validate everything and mutate nothing: orders place returns testMode: true, no card is sent, no credit is spent. The CLI prints a banner whenever a response is test-mode, so a test key can't be mistaken for a real send.

Note that test keys cannot create webhooks — that needs a live_ key.

Usage

# Preview before spending credit — returns a watermarked proof and the cost
cardly orders preview --artwork thank-you-01 \
  --to-first-name Ada --to-address "12 Analytical Way" \
  --to-city Melbourne --to-country AU \
  --message "Thanks for everything!" \
  --download proof.pdf

# Send
cardly orders place --artwork thank-you-01 \
  --to-first-name Ada --to-address "12 Analytical Way" \
  --to-city Melbourne --to-country AU \
  --message "Thanks for everything!"

# Anything without a dedicated command
cardly api GET users

orders place/orders preview also accept a raw --data/-d JSON body (inline, @file, or - for stdin). A body carrying lines[] is treated as the complete request and is mutually exclusive with the card-shaping flags (--artwork, --to-*, --message, ...) — mixing the two errors rather than silently picking one. place sends lines[] as-is (it's already the shape the endpoint wants); preview takes a single flat card, so it unwraps a one-element lines[] rather than rejecting it — --data lines[] with more or fewer than one element is an error.

Global flags: --json, --jq, --quiet, --verbose, --no-color, --profile, --api-key, --base-url, --config-path, --no-retry, --max-retries, --idempotency-key.

Environment variables: CARDLY_API_KEY, CARDLY_BASE_URL, CARDLY_PROFILE.

Exit codes

Code Meaning
0 Success
1 Generic failure (includes 422 validation)
2 Config/credential resolution
3 401/403 — bad key or insufficient privileges
4 404
5 429 after retries
6 5xx after retries
7 Network failure or timeout
8 402 — insufficient credit

8 is separate because it's the one failure a scheduled job must treat differently: not transient, not a bug, and no amount of retrying will conjure credit.

Safety

Every POST carries an Idempotency-Key (a fresh UUID per invocation, reused across that invocation's retries). Pin one with --idempotency-key for scripted retries. This is what makes retrying a timed-out orders place safe: if the order landed and only the response was lost, the replay returns the stored result rather than sending a second card.

If a POST times out

If every retry attempt on a POST (e.g. orders place) times out, the CLI cannot tell you whether the card was mailed — only that the response never came back. It exits 7 with a message like:

Error: Cardly POST orders/place timed out after 4 attempts. The order MAY have been
placed — check `cardly orders list` before retrying. To retry safely, re-run with
--idempotency-key <the-key-that-was-used>

Do this, in order:

  1. Check first: run cardly orders list (or cardly orders get <id> if you have reason to guess an id) before doing anything else. If the order is there, you're done — do not resend.
  2. If you must retry, re-run the exact same command with --idempotency-key <the-key-printed-above>. Cardly will replay the stored result from the original attempt instead of mailing a second card, if the first attempt actually landed.
  3. Never just re-run the bare command. Each invocation mints a fresh Idempotency-Key by default — a second invocation with no key pinned is a genuinely new write, and if the first one landed, you now have two cards mailed and two charges.

Run with --verbose up front on anything that spends credit if you want the key logged as it's used, rather than only after a timeout.

contacts update sends whatever fields you pass as a POST — it is documented and tested against the belief that this merges rather than replaces the contact. That belief is unverified (see "Known-unverified" below); if it turns out to replace, a single-field edit would silently wipe the rest.

contacts delete-all requires an explicit --data body — Cardly's docs don't say what a bodyless call to that endpoint does, and it could mean "delete every contact in the list." The CLI refuses to guess. cardly api DELETE contact-lists/<id>/contacts remains the escape hatch if you need to call it bodyless anyway.

Pagination

Verified against the live API (api.card.ly, 2026-07-15, real sandbox key):

  • limit is honoured, clamped on both ends: a floor of 5 (asking for 1–5 all return 5 records) and a ceiling of 250 (asking for 251+ all return 250; meta.limit echoes the clamped value).
  • Cardly paginates by page, not offset. offset is a response field only (meta.offset, computed server-side as (page-1) * limit) — sending it as a request parameter does nothing. GET /v2/doodles?limit=5&page=2 returns records 6–10; GET /v2/doodles?limit=5&offset=5 returns records 1–5 again (page 1, offset ignored entirely). Walking page=1..5 against a 443-record /doodles list retrieved all 443 unique records with no duplicates.

--all walks pages (page=1, 2, 3, ...) until the API reports meta.lastRecord >= meta.totalRecords, a page comes back short of the requested limit, or a page comes back empty — so it retrieves everything, not just the first page. 250 is the max page size, not a cap on how many records can be retrieved in total.

Cardly's own documentation is wrong here. It states that list endpoints "accept limit and offset" and instructs you to "increase the offset parameter by the limit value" to walk pages, with a worked example (GET /v2/art?limit=10&offset=20). That example does not work against the live API — offset is silently ignored as a request parameter. This CLI follows the API's actual behaviour (page), not the documented one. If a future change reverts this back to offset because the docs say so, --all will silently go back to returning only the first page.

To inspect the raw pagination metadata for any endpoint, use the raw envelope, which includes meta.page, meta.lastRecord, and meta.totalRecords:

cardly api GET doodles -p limit=250 -p page=1

Verifying webhook postbacks

cardly webhooks verify postback.json --secret sh-xxx

Cardly's docs describe two signing schemes and share one worked example between them, so the example can't tell them apart. This command tries whichever the inputs allow and tells you which matched:

# Body scheme: timestamp/data/signatures inside the payload
cardly webhooks verify postback.json --secret sh-xxx

# Header scheme: Cardly-Timestamp + Cardly-Signatures
cardly webhooks verify body.json --secret sh-xxx \
  --header "Cardly-Timestamp=$TS" --header "Cardly-Signatures=$SIGS"

If you run a real postback through it, we'd love to know which scheme matched — see "Known-unverified" below.

Known-unverified

This CLI's tests are fully mocked, by design. That means they confirm we send what we believe Cardly wants — they can't confirm the belief. These points are recorded from Cardly's docs and a working n8n integration, but are not confirmed against the live API. Passing CI must not be read as having checked any of these:

  • Which webhook signature scheme is live. Cardly's docs describe two schemes and both cite the same worked example, so the example can't discriminate between them, and neither has been validated against a real postback. webhooks verify tries both and reports which matched.
  • Pagination beyond what's below. Live testing against a real sandbox key (2026-07-15) verified limit/page behaviour directly — see "Pagination" above for what's now known. What remains genuinely untested (too few records to exercise a second page): /contact-lists, /contact-lists/{id}/contacts, /webhooks, /users, /invitations, /orders, /templates, /writing-styles, /art. Page-based paging is expected to hold on these too — it's the API's documented mechanism, just not the one the docs describe correctly — but it hasn't been exercised against a real multi-page response for them.
    • Whether the floor of 5 and ceiling of 250 are global constants or set per-endpoint. Only /doodles and /media were actually measured at both ends of the range.
  • Whether credit-history accepts date-only filters. We pad to midnight rather than find out in production.
  • Rate limits. A 429 exists; no numbers and no RateLimit-* headers are documented. We back off adaptively.
  • Whether contacts update merges or replaces. If it replaces, a single-field edit via the CLI would wipe every other field on the contact. The command's help text documents this uncertainty; treat a partial update as a full overwrite until proven otherwise.
  • What a bodyless contacts delete-all does. It could mean "delete every contact in the list." The CLI requires an explicit --data body rather than find out; cardly api DELETE contact-lists/<id>/contacts remains the escape hatch.
  • Which Content-Type Cardly's live API actually wants. Cardly's prose docs say text/json; the OpenAPI schema says application/json. We send application/json because a working n8n integration uses it successfully — that's the only evidence we have, and it has not been verified directly against the live API here.
  • Cardly's request-body size limit for artwork uploads. Undocumented, and unmeasured since the tests are fully mocked. Base64 inflates the payload by exactly 4/3, so a 4-page 2 MB/page card becomes 10.67 MB encoded. The CLI warns above 10 MB encoded and lets the API decide, rather than inventing a limit that might reject a body Cardly would actually accept.
  • Whether art update merges or replaces. Same uncertainty as contacts update: only the fields you pass are sent, and if Cardly replaces the record a single-field edit would clear the rest.

Issues and corrections very welcome.

Deliberate omissions

These are facts about the API, not gaps:

  • No orders cancel — cancellation is portal-only, despite a contact.order.refunded webhook event existing.
  • No lists update — Cardly has no contact-list update endpoint; a list's name and description can't be edited via the API.
  • No gift-card flag — gift cards attach by selecting a template that contains one. There's no API to mint or choose one ad hoc.
  • No PUT — Cardly uses POST for updates throughout.
  • invitations list hides accepted invitations by default — pass --include-accepted to see them. This is Cardly's behaviour, not ours.

Uploading artwork

Artwork needs a media UUID (the card stock it prints on). List your options first:

cardly ref media                       # pick the UUID you want
cardly art upload --media <uuid> --name "Thank you" \
  --artwork front.png --artwork inside.png

Bare --artwork paths are numbered from 1 in order; page 1 is the front. Use --artwork 3=back.png to set a page explicitly.

Images are base64-encoded into a JSON body — Cardly does not accept multipart uploads. Base64 inflates the payload by exactly a third (4/3), so a 4-page card at 2 MB per page becomes ~10.7 MB on the wire. Measured: a 1-page (2 MB) or 2-page (2 MB/page) card stays under 10 MB encoded and prints no warning; a 4-page card (2 MB/page, 10.67 MB encoded) or a 4-page hi-res card (5 MB/page, 26.67 MB encoded) does. The CLI warns above 10 MB encoded; that warning is expected on a 4-page card, not a sign anything is wrong — it's a heads-up, not an error, and the API still decides whether to accept the body.

art update sends only the fields you pass, same as contacts update — see "Known-unverified" above.

Users and invitations

invitations list hides accepted invitations by default — that's Cardly's behaviour, not ours. Pass --include-accepted to see them:

cardly users list
cardly invitations list --include-accepted

Development

uv sync --all-extras --dev
uv run pytest -q
uv run ruff check src tests && uv run black --check src tests && uv run mypy

Tests never touch the network and require no credentials.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cardly_cli-0.2.1.tar.gz (194.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cardly_cli-0.2.1-py3-none-any.whl (60.3 kB view details)

Uploaded Python 3

File details

Details for the file cardly_cli-0.2.1.tar.gz.

File metadata

  • Download URL: cardly_cli-0.2.1.tar.gz
  • Upload date:
  • Size: 194.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for cardly_cli-0.2.1.tar.gz
Algorithm Hash digest
SHA256 76845dc18195f706367f30eeba9ab2f7c8c73781bc581e988af533dbe268871a
MD5 6c9433432c4381cb257be3571225f0b2
BLAKE2b-256 6250c18cf3bea5b4e2af37b1ab6be1120b351e30d4940e6e19c190f360b69ef6

See more details on using hashes here.

Provenance

The following attestation bundles were made for cardly_cli-0.2.1.tar.gz:

Publisher: publish.yml on alphaomegateam/cardly-cli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cardly_cli-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: cardly_cli-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 60.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for cardly_cli-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 c262b1d4c9e0374a481e597a145f7eec14103ac7b344942a5f66727bd10bc778
MD5 64750a36c0b5a694a617cbfb7372c04e
BLAKE2b-256 0971c38d80e5c18ecdb7f9f1f71338587523da4ed3c44389b88ab4c712c31edc

See more details on using hashes here.

Provenance

The following attestation bundles were made for cardly_cli-0.2.1-py3-none-any.whl:

Publisher: publish.yml on alphaomegateam/cardly-cli

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page