Connect your own product
The codedock.source/1 contract: three read-only GET endpoints, one token, full pulls — how a product that issues its own codes hands them to CodeDock.
Who should connect
The contract answers one question: how does a product that issues its own promo codes hand those codes to CodeDock.
“Issues its own” is the only requirement — where the product runs does not matter: a SaaS website, an Android app with a redeem entry, desktop software sold as license codes, a game handing out gift codes, an iOS app selling subscriptions on the web. As long as your own backend issues and redeems the codes, it applies. App Store promo codes and offer codes do not go through this contract; they are imported as files.
Products you can connect
The contract is deliberately tiny — closer to RSS than to OAuth: no callbacks, no authorization page, no middle server. The implementer is usually a CodeDock user themselves.
The connection
In CodeDock, the user fills in two things: a base URL and a token. Every request then looks like this:
GET {base}/v1/manifest HTTP/1.1
Authorization: Bearer cdk_ro_9f2c…
Accept: application/json| Field | Example | Notes |
|---|---|---|
| Base URL | https://inkwell.app/api/codedock | The {base} below, without /v1 |
| Token | cdk_ro_9f2c… | Sent as-is in the Authorization header |
- {base} must be https. http://localhost, 127.0.0.1, and *.local are excepted for local development — a token crossing the public internet in cleartext is as good as pasted into a chat.
- The token must be read-only and scoped to the three endpoints in this contract.
- CodeDock stores it in the device Keychain and keeps it out of backup files — after restoring onto a new phone it has to be entered again, on purpose.
The three endpoints
All three endpoints accept GET only and return application/json (UTF-8). All timestamps are ISO 8601 with a timezone (UTC recommended).
The client ignores fields it does not recognize, so within v1 things only get added: new fields never require a version bump.
GET /v1/manifest
Who you are, and what you can report.
{
"spec": "codedock.source/1",
"product": {
"id": "prod_inkwell",
"name": "Inkwell",
"website_url": "https://inkwell.app",
"icon_url": "https://inkwell.app/icon.png",
"redeem_url_template": "https://inkwell.app/redeem?code={code}"
},
"capabilities": ["redemption_counts"]
}| Field | Required | Notes |
|---|---|---|
| spec | Yes | Fixed at codedock.source/1. A mismatch and CodeDock refuses the connection |
| product.id | Yes | A stable product ID. If the same connection switches tokens and starts pointing at another product, CodeDock refuses the sync |
| product.name | Yes | The default name when the profile is created. The user can change it afterwards |
| product.website_url | No | Product homepage |
| product.icon_url | No | A square PNG/JPEG, downloaded once at profile creation and resized to 128×128 |
| product.redeem_url_template | No | Redemption link template, must contain {code}. CodeDock percent-encodes the code, substitutes it, and prints it as the QR code on share cards. https is recommended (App Links / Universal Links open your app directly); a custom scheme like inkwell://redeem?code={code} also works and only opens for people who have the app installed. javascript:, data:, and file: are rejected. Without it, cards carry no QR code |
| capabilities | Yes | Capability list, may be an empty array. v1 defines exactly one: redemption_counts — every code carries times_redeemed. With it declared, “redeemed” is whatever your backend says and the owner can no longer mark it manually; without it, CodeDock shows no redemption counts (unknown is not zero) |
GET /v1/offers
Offers. One offer maps to one batch of codes in CodeDock. Paginated, see below.
{
"data": [
{
"id": "offer_press",
"name": "Press preview",
"summary": "3 months of Pro, free",
"usage": "single_use",
"status": "active",
"expires_at": "2026-12-31T23:59:59Z"
}
],
"next_cursor": null
}| Field | Required | Notes |
|---|---|---|
| id | Yes | Stable ID, unique within your source and never reused |
| name | Yes | Becomes the batch name on first sync only; afterwards the owner manages it |
| summary | No | The one line printed for the recipient (e.g. “3 months of Pro free”). It appears on share cards — not the place for internal codenames |
| usage | Yes | single_use: one code, one person, void after one redemption (like an Apple promo code); multi_use: one code, many people (like LAUNCH50). It decides how CodeDock keeps the books and must not change after the offer is created |
| status | Yes | active or archived. Codes of an archived offer are all treated as disabled |
| expires_at | No | Offer deadline; null means no deadline |
GET /v1/codes
Codes. Paginated, see below.
{
"data": [
{
"id": "code_8x2k",
"offer_id": "offer_press",
"code": "INK-7KQ2-M9XD",
"status": "active",
"max_redemptions": 1,
"times_redeemed": 0,
"last_redeemed_at": null
}
],
"next_cursor": "b2Zmc2V0OjEwMA"
}| Field | Required | Notes |
|---|---|---|
| id | Yes | Stable ID, unique within your source and never reused |
| offer_id | Yes | The owning offer. Codes pointing at a nonexistent offer are ignored |
| code | Yes | The code itself. For the same id, code must never change |
| status | Yes | active or disabled |
| max_redemptions | Yes | Redemption cap; null means unlimited. Under a single_use offer it must be 1 |
| times_redeemed | Required when redemption_counts is declared | Cumulative redemptions |
| last_redeemed_at | No | Most recent redemption time; CodeDock shows it as “redeemed at” |
Pagination
/v1/offers and /v1/codes both paginate by cursor:
GET {base}/v1/codes?limit=100
GET {base}/v1/codes?limit=100&cursor=b2Zmc2V0OjEwMA- limit defaults to 100 and caps at 500.
- A null next_cursor in the response means the end. Cursors are opaque to the client.
- Within a single sync, the server should keep pages stable — no duplicates, no gaps (sorting by id or creation time is enough).
Sync semantics — how CodeDock reads your data
Every sync pulls all pages of both collections and reconciles the local side against that snapshot. v1 has no incremental sync (no updated_since) and therefore needs no tombstones: absent from the snapshot means deleted.
CodeDock never writes back: hand out a code in the app and your server never knows. Single-use codes are best handed out through CodeDock alone; if the same code goes out twice from two places, CodeDock cannot deduplicate that for you.
If any page returns non-2xx, times out, or fails to parse, the whole sync is abandoned and local data stays untouched. The UI says “last sync failed · data is 3 hours old” instead of pretending the data is fresh.
Frequency: CodeDock syncs when the app returns to the foreground and the last attempt is more than 10 minutes old; pull-to-refresh syncs immediately. It does not poll in the background.
Who decides what
| Your server decides | CodeDock (the owner) decides |
|---|---|
| Offer deadline, the line shown to recipients, whether it is archived | Batch name, tags, expiry reminders, owner |
| Whether a code is usable, its redemption cap, its redemption count | Who it went to, through which channel, under which offer |
| Redemption link template, product homepage | Profile name, icon |
How states change
- A single-use code with times_redeemed ≥ 1 that CodeDock handed out: marked “redeemed” and leaves the follow-up list.
- A single-use code with times_redeemed ≥ 1 that CodeDock never handed out: recorded as “redeemed elsewhere” — out of stock, counted as redeemed, attributed to no one.
- times_redeemed falling back to 0 (e.g. a refund reverting a redemption): the “redeemed” mark is undone and the code returns to sent-but-unredeemed.
- A code disabled, an offer archived, or a code missing from the snapshot: un-sent ones leave the stock; sent ones stay on the record, marked disabled or removed.
- Deleting a person never deletes the record; the server deleting a code does not either. “Who you sent it to in March” has already happened.
Errors
Express the category with an HTTP status code and give one readable reason in the body, e.g. {"error":{"code":"invalid_token","message":"Token revoked"}}.
| Status | What CodeDock says |
|---|---|
| 401 / 403 | Token invalid or revoked — enter it again |
| 404 | Wrong base URL |
| 429 / 5xx | Backend temporarily unavailable, try again later |
| Other | The message is shown as-is |
Compared with established platforms
These fields were not invented from scratch — they are the greatest common divisor of the promo-code models of the mainstream payment platforms. If your codes already live in one of them, implementing this contract is mostly a field rename. If one Stripe Coupon mixes single-use and multi-use codes, split it into two offers — usage is the watershed for how CodeDock keeps the books.
| CodeDock | Stripe | Lemon Squeezy | Paddle Billing |
|---|---|---|---|
| offer | Coupon | Discount | Discount |
| code | Promotion Code | Discount’s code | Discount’s code |
| usage | derived from max_redemptions | derived from max_redemptions | derived from usage_limit |
| status | active | status (published / draft) | status (active / archived) |
| max_redemptions | max_redemptions | max_redemptions (is_limited_redemptions) | usage_limit |
| times_redeemed | times_redeemed | Discount Redemptions count | times_used |
| expires_at | expires_at / redeem_by | expires_at | expires_at |
Versioning
The spec field and the /v1 in the path evolve together. Breaking changes go to /v2; v1 endpoints should keep working. Within v1, fields only get added: the client ignores unknown fields and unknown capabilities; an unknown usage makes it skip that offer, and an unknown status is treated as disabled — better to under-report than to report wrong.
Free and Pro
The free tier includes one product profile and ten contacts, and covers importing App Store codes, sending, reminders, and history. Connecting your own backend is part of Pro. For the full differences between free and Pro, see the FAQ.
FAQ