Dodo Payments license API gotchas: 201 not 200, and the live host

What a Swift client needs to know about activate, validate and deactivate, checked against the real endpoints

By Arin Prajapati 10 min read
Black cover with the NotchStar logo and the headline License API gotchas, with the line 201 not 200, and the live host

TL;DR

  • Dodo Payments exposes public license endpoints (activate, validate, deactivate) that need no API key, so a macOS app can check a license without shipping a secret.
  • Activation succeeds with 201, not 200. A client that tests status == 200 treats every successful activation as a failure.
  • The documentation page shows only the test host. The production host for the same endpoints is live.dodopayments.com. Every status code below was either read from the client, run against the endpoints on 4 November 2026, or taken from the docs page fetched the same day, and each is labelled.

Problem

NotchStar sells for a one time price through Dodo Payments, which acts as the merchant of record. After a purchase the buyer gets a license key. The app has to turn that key into “this Mac is licensed” without a server of its own and without an account. Dodo offers license endpoints for exactly this, and the app’s client is a single file, Sources/NotchStar/Licensing/DodoLicenseClient.swift, 89 lines (wc -l, 4 November 2026).

The surface looks small: three POST requests. The surprises are in the status codes, the hosts and the error bodies. This post lists them in the order they cost time. How the app stores the key and decides when to ask again is a separate post, on the license client.

The three calls

All three are JSON POSTs relative to a base URL. The fields below come from the client, and the docs page (fetched 4 November 2026) agrees on the names.

CallPathBody fieldsSuccess
Activatelicenses/activatelicense_key, name201 with an instance id starting lki_ and the customer
Validatelicenses/validatelicense_key, license_key_instance_id200 with {"valid": true} or {"valid": false}
Deactivatelicenses/deactivatelicense_key, license_key_instance_id200 to 204 accepted by the client

The name on activation is a label for the device. The app sends the Mac’s localized name, or Mac if it has none. The returned instance ID is what makes a later validation or deactivation specific to one Mac, so the app stores it next to the key.

The docs page describes these endpoints as public and not requiring an API key. A unit test pins that down for the app: testActivateSendsTheRightRequestAndNoCredentials asserts the request has no Authorization header, with the message “the public endpoints must never carry a secret”.

Gotcha 1: activation answers 201

The first line of the activation switch in the client is:

switch status {
case 200, 201:
    struct Body: Decodable { struct Customer: Decodable { let email: String? }; let id: String; let customer: Customer? }
    guard let body = try? JSONDecoder().decode(Body.self, from: data) else { throw LicenseError.server(status) }
    return Activation(instanceID: body.id, email: body.customer?.email)

The doc comment above the client says where the 201 comes from: “Behaviour verified against the real test API (activate 201, validate 200 with a valid flag, 4th activation 422)”. That was observed by running the client against Dodo’s test mode, not guessed.

201 Created is a correct answer for a request that creates an instance. It is also the answer that a hand written client gets wrong, because the usual check is (200...299) in a generic helper, == 200 in a quick one, or response.ok in JavaScript, which is true for 201. In Swift, the mistake looks like this:

// Wrong: every successful activation is reported as an error
guard status == 200 else { throw LicenseError.server(status) }

The mock in the unit tests answers activation with 201 on purpose, so a regression to == 200 fails testActivateParsesTheInstanceAndEmail. The client still accepts 200, because nothing in a status code contract forbids it, and a wrong guess in that direction costs nothing.

A second detail from the project notes (blog/FACTS.md, item B8): when the test key was created through the API, that call also returned 201, and it needed an explicit key value to be supplied. That setup step happens outside the app and is not in this repository, so its request shape is not reproduced here.

Gotcha 2: the live host is not in the docs example

The base URL is chosen from the build configuration:

enum LicenseConfig {
    /// "live" in Release, "test" in Debug (set in project.yml), so development never touches real licences.
    static var environment: String { Bundle.main.object(forInfoDictionaryKey: "NotchStarLicenseEnvironment") as? String ?? "live" }
    static var baseURL: URL { URL(string: environment == "test" ? "https://test.dodopayments.com" : "https://live.dodopayments.com")! }
}

project.yml sets NOTCHSTAR_LICENSE_ENV: live for the base configuration and test for Debug, and the Info.plist key reads it. A Debug build can therefore never activate a real customer’s key, and a Release build never calls the test host. The fallback when the key is missing is live, which is a choice worth making on purpose: a missing value in a shipped build must not silently switch to test.

The docs page for license keys, fetched on 4 November 2026, shows the test host in every cURL example (https://test.dodopayments.com/licenses/activate) and does not give a production host. The production host for these public endpoints is live.dodopayments.com. The checkout function on the site uses the same pair for creating checkout sessions, switched by a DODO_ENVIRONMENT variable:

const live = process.env.DODO_ENVIRONMENT !== 'test';
const host = live ? 'https://live.dodopayments.com' : 'https://test.dodopayments.com';

Both hosts answer the validate call the same way. Run with a made up key on 4 November 2026:

curl -s -X POST https://live.dodopayments.com/licenses/validate \
  -H 'Content-Type: application/json' \
  -d '{"license_key":"NOPE-0000","license_key_instance_id":"lki_nope"}'
{"valid":false}
HTTP 200

Same body and status from test.dodopayments.com. Nothing changes on either side from that call, and the key is not a real one.

What the bare domain does

The project notes record that the bare dodopayments.com host answered 403 to non browser clients. That was not reproduced when the same POST was sent on 4 November 2026: with Accept: application/json the bare host answered 406 (“Supported types: text/html, text/markdown”), with no Accept header it answered 404, and www.dodopayments.com answered a 301. The status changed, the lesson did not. The bare domain is the marketing site. It is not an API host, and a client pointed at it never reaches the license service, whatever number comes back.

Gotcha 3: what the errors look like

The activation error mapping is a small switch:

case 403, 404: throw LicenseError.invalidKey
case 422:
    if String(decoding: data, as: UTF8.self).contains("LICENSE_KEY_LIMIT_REACHED") { throw LicenseError.limitReached }
    throw LicenseError.server(422)
default: throw LicenseError.server(status)

The docs page lists three activation errors: 403 if the key is not active, 404 if it does not exist, and 422 when the key has reached its activation limit. It does not name an error code or describe an error body. The code LICENSE_KEY_LIMIT_REACHED in the client came from the real test API, where the fourth activation of a three device key answered 422. The client looks for the string in the body instead of parsing JSON, so a changed body shape still maps correctly as long as the code appears. A 422 without that string becomes a generic server error instead of a wrong claim about the limit.

A wrong key on activation, run against the live host on 4 November 2026 with a made up key:

{"code":"NOT_FOUND","message":"The requested resource could not be found. ..."}
HTTP 404

That maps to invalidKey, and the user sees “That key isn’t valid. Check it for typos and try again.”

Each error has a human sentence in LicenseError.message, written to say what happened and what to do, never a bare status code. The limit message tells the user the key is active on 3 Macs and that one can be deactivated in its Settings.

Validate is a different shape

Validation does not answer a wrong key with an error. It answers 200 with valid: false, as the curl run above shows. The client handles both shapes anyway:

switch status {
case 200:
    struct Body: Decodable { let valid: Bool }
    guard let body = try? JSONDecoder().decode(Body.self, from: data) else { throw LicenseError.server(status) }
    return body.valid
case 404: return false
default: throw LicenseError.server(status)
}

The docs page documents no status codes for validate, only that valid is true when the key is active and unexpired. The 404 branch is defensive. Everything else, including a 200 whose body cannot be decoded, throws. That asymmetry is the point: the return type is Bool, but the function has a third outcome, “I do not know”, and it expresses it by throwing. The caller can then keep the old state.

The wrong turn: reading a status code as a verdict

The first instinct for a client like this is a helper that returns a bool for “was it a success”. For activation that fails on 201. For validation it conflates two different things: “the server said no” and “the request did not work”. A license check that revokes on a timeout would lock out people on planes. The client keeps the cases apart:

SituationClient resultWhat the app does
200, valid: truetrueRecords the check time
200, valid: falsefalseMarks the license revoked
404 on validatefalseMarks the license revoked
200 with a body that does not decodethrows server(200)Nothing changes
500 or any other statusthrows server(code)Nothing changes
No connection or a timeoutthrows networkNothing changes

Network errors are collapsed in post, which catches anything the session throws and rethrows LicenseError.network. The request has a 15 second timeout, a User-Agent of NotchStar/<version> and explicit JSON Content-Type and Accept headers. The explicit Accept is not decoration: the bare domain above answers differently depending on it.

Deactivation treats “already gone” as success:

guard (200...204).contains(status) || status == 404 else { throw LicenseError.server(status) }   // already gone is fine

Testing it without the network

Two layers of tests keep this honest. The mock layer uses a URLProtocol subclass that answers from a closure, so the six client tests run with no network. They check the parsing of the instance and email, the exact request (URL, method, no credentials, user agent, trimmed key), the error mapping for 404, 403, 422 and 500, that a thrown URLError becomes .network and not .invalidKey, the boolean in validate, and that deactivate sends both identifiers.

The live layer is LiveDodoTests, which is skipped unless a test key is supplied through an environment variable, so CI never needs a network. When supplied, it validates a wrong key (expects false), activates (expects an ID with the lki_ prefix), validates (expects true), deactivates, validates again (expects false: “a deactivated instance must stop validating”), and activates a wrong key (expects invalidKey). Together those cover the test mode lifecycle. The point to carry over: a mock confirms that your code handles the shape you believe in, and only a live call confirms the belief.

What is still bad

  • The live purchase has not been run end to end by an agent. The 3 device limit and the validation lifecycle were exercised against Dodo’s test API and mocks. A real purchase through the live host to a real activation is not covered by any recorded run (blog/FACTS.md, section C).
  • The 403 from the bare host is not reproducible today. It was recorded in the project notes and the same request now returns 406 or 404. The conclusion (do not use that host) holds. The exact status is not a stable fact.
  • The docs page and the behaviour differ. The page lists no live host, no error body and no validate status codes. Behaviour was filled in by running calls. If Dodo changes an undocumented body, the client falls back to a generic server error, which is safe and unhelpful.
  • The 422 check is a substring. It is forgiving, and it would also match any 422 body that happens to contain the text.
  • A 404 on validate revokes. The live API answers a wrong key with 200 and valid: false, so the branch may never fire, but it is there.
  • One shape of failure message. A user behind a proxy that rewrites responses would see “The license server had a problem (code N)”. That is accurate and not very helpful.

Try it yourself

You can check the contract without the app. Use a made up key so nothing real is touched.

  1. Send a validate call to the test host and the live host and compare:
for h in test live; do
  curl -s -o /dev/null -w "$h %{http_code}\n" -X POST "https://$h.dodopayments.com/licenses/validate" \
    -H 'Content-Type: application/json' \
    -d '{"license_key":"NOPE-0000","license_key_instance_id":"lki_nope"}'
done
  1. Send an activate call with a made up key and read the status and the code field:
curl -s -w '\nHTTP %{http_code}\n' -X POST https://live.dodopayments.com/licenses/activate \
  -H 'Content-Type: application/json' -d '{"license_key":"NOPE-0000","name":"check"}'
  1. In your own client, write the table above as tests with a URLProtocol mock before you write the switch. Make activation answer 201 first.
  2. Switch the host by build configuration, and default to live when the setting is missing.
  3. Only after that, run one full lifecycle against test mode with a key you minted there: activate, validate, deactivate, validate.

Cheat sheet

ThingValue
Production hosthttps://live.dodopayments.com
Test hosthttps://test.dodopayments.com
Auth on these endpointsNone, so no secret in the app
Activate success201 with id (lki_...) and customer.email
Activate, unknown key404 (observed live on 4 November 2026)
Activate, limit reached422 with LICENSE_KEY_LIMIT_REACHED in the body
Validate, unknown key200 with {"valid":false} (observed on both hosts)
DeactivateAccept 200 to 204, and 404 as already gone
Timeout used15 seconds
Build switchNOTCHSTAR_LICENSE_ENV, test in Debug, live otherwise
  • Accept 201 on activation
  • Use the live host in Release and the test host in Debug
  • Default to live when the config is missing, and test that
  • Separate “the server said no” from “the request failed”
  • Never mark a license invalid on a timeout, 5xx or undecodable body
  • Match the limit error by its code, not only by the status
  • Send explicit Content-Type and Accept headers
  • Run one live lifecycle in test mode before you ship

If you want to see the result, NotchStar is at notchstar.com. If a status code here has changed on Dodo’s side by the time you read it, write to hello@notchstar.com and the post will be corrected.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.