For developers

Provider API and MCP server

Publish your catalog over REST or point your own agent at our MCP server. Validation is automated and the same pipeline reviews every submission, pulled or pushed. There is no queue waiting on a human.

Base URL and conventions

Everything below lives under https://esimradar.com/api/v1. The rules on this card hold for every endpoint, so the sections that follow only note where something departs from them.

Validate before you push

GET /api/v1/schema returns the catalog JSON Schema. It takes no key and is not rate limited, so the cheapest place to catch a malformed catalogue is your own build rather than a submission. It is the same set of definitions the MCP tools validate against.

Request

curl https://esimradar.com/api/v1/schema -o catalog.schema.json

Quick start

Five calls, in the order they have to happen. The first two run once for the life of the account; the last three are the loop you automate.

# 1. register. The apiKey in this response is the only copy you will get.
curl -X POST https://esimradar.com/api/v1/providers/register \
  -H 'content-type: application/json' \
  -d '{ "name": "Example eSIM",
        "websiteUrl": "https://example-esim.com",
        "contactEmail": "[email protected]" }'

export ESIMRADAR_KEY='er_live_...'

# 2. confirm the address. Nothing publishes until this succeeds.
curl -X POST https://esimradar.com/api/v1/providers/verify \
  -H 'content-type: application/json' \
  -d '{ "token": "6Yk2..." }'

# 3. push the catalogue. One call, full replace, however many plans.
curl -X PUT https://esimradar.com/api/v1/catalog \
  -H "authorization: Bearer $ESIMRADAR_KEY" \
  -H 'content-type: application/json' \
  --data-binary @catalog.json

# 4. read back what was accepted, and why anything was not.
curl https://esimradar.com/api/v1/catalog/status \
  -H "authorization: Bearer $ESIMRADAR_KEY"

# 5. check the account the site sees.
curl https://esimradar.com/api/v1/me \
  -H "authorization: Bearer $ESIMRADAR_KEY"

After that, a daily or weekly push keeps the listing current, and a single-plan update covers a price change in between.

Register and authenticate

Registration is open and needs no key of its own. It returns your provider id, your slug and an API key, and the key appears exactly once. Every other call in this API is authenticated with it.

Request

curl -X POST https://esimradar.com/api/v1/providers/register \
  -H 'content-type: application/json' \
  -d '{
    "name": "Example eSIM",
    "websiteUrl": "https://example-esim.com",
    "contactEmail": "[email protected]"
  }'

Response

HTTP/1.1 201 Created
content-type: application/json
cache-control: no-store

{
  "providerId": "9d2b41f6-8c07-4d2a-9a51-1f0e7c4b3a12",
  "slug": "example-esim",
  "trustTier": "unverified",
  "apiKey": "er_live_9tK4mQ2xR7vB1nZ8sD3wY6cF0hJ5lP2aT4uE7oI1kM0",
  "emailVerificationRequired": false,
  "verificationToken": "6Yk2wQ8pN4vL7bT1sX9dR3fH5jC0mG2aZ6eU8iO4yK1",
  "verificationExpiresAt": "2026-07-28T09:14:02Z",
  "email": "skipped",
  "listing": {
    "listed": false,
    "reason": "domain_unverified",
    "waitingOn": "you",
    "since": null,
    "detail": "Prove you control your website before your plans can be listed. POST /api/v1/domain-challenge for a token, publish it at /.well-known/esimradar-challenge.txt or in a <meta name=\"esimradar-site-verification\"> tag on your home page, then POST /api/v1/domain-verify. Nothing is stored while this is outstanding, so push your catalog again once it passes."
  }
}

201 Created. Store apiKey immediately: it is in no later response and we cannot recover it. emailVerificationRequired now reads false, because confirming the address no longer gates anything. Read the listing block instead: it names the first gate between you and being listed, which is proving your domain.

Your API key

A key is er_live_ followed by 43 URL-safe characters, which is 256 bits of entropy. We store only its SHA-256 hash, so no support conversation and no database dump can produce the plaintext again. One key per account today, scoped to catalog:write.

Keep it in an environment variable or a secret manager and inject it at deploy time. It belongs in your build system, not in your repository, and never in anything a browser downloads. Treat a key that has been pasted into a chat, a ticket or a log as compromised.

Rotating your key

A rotation issues a new key and retires the old one in the same call, authenticated with the key it replaces. Reach for it when a key has leaked, when the person who held it has left, or on whatever schedule your own policy sets. The new key is in the response and, like the first one, that is the only place it ever appears.

Request

curl -X POST https://esimradar.com/api/v1/keys/rotate \
  -H 'authorization: Bearer er_live_...' \
  -H 'content-type: application/json' \
  -d '{ "label": "ci-2026-08" }'

# the body is optional in full
curl -X POST https://esimradar.com/api/v1/keys/rotate \
  -H 'authorization: Bearer er_live_...'

Response

HTTP/1.1 200 OK
cache-control: no-store

{
  "apiKey": "er_live_4bV8xN1qL6zR9tD2wS5yG7cH0mJ3pA6uE8oI4kT2fW1",
  "keyPrefix": "er_live_4bV8xN",
  "scopes": ["catalog:write"],
  "rotatedAt": "2026-07-27T11:18:44Z",
  "revoked": [
    { "keyPrefix": "er_live_9tK4mQ", "createdAt": "2026-07-27T09:14:02Z" }
  ]
}

It retires every live key on the account, not only the one that made the call, and lists what it retired by prefix. That is deliberate: a rotation reports itself as closing a leak, and leaving a second key alive would make that report false. If a prefix comes back that you did not know about, it existed and it does not any more.

The new key is written before the old ones are retired, so a rotation that fails halfway leaves you holding a working key rather than none. If the response never arrives, call again with either key: the worst case is an account with two live keys and one more rotation to do, never an account locked out by its own request.

If you lose your key

Rotation cannot help here, and it is worth being plain about why. Every endpoint on this page authenticates with a key, rotation included, so a key nobody holds has nothing to present. There is no version of that this API can solve on its own.

Do not register again. Registration does not recognise a site you have already claimed: it allocates the next free slug, so a second attempt on the same name creates a second provider with a second catalogue, and your listing ends up split across two records that neither of us can merge from the API.

So a genuinely lost key goes back to the mailbox. Write to [email protected] from the contact address the account is registered to, and we will retire the old key and issue a new one by hand. Sending it from that mailbox is what proves the account is yours, which is the same test registration applied in the first place.

What a refused key looks like

Response

HTTP/1.1 401 Unauthorized

{
  "error": {
    "code": "unauthorized",
    "message": "Provide an API key as `Authorization: Bearer er_live_...`."
  }
}

HTTP/1.1 401 Unauthorized

{
  "error": {
    "code": "unauthorized",
    "message": "Invalid or revoked API key."
  }
}

The first shape is a missing or malformed header, and it says what the header should look like. The second covers a key that does not exist, has been revoked, or was mistyped, and it is deliberately one message for all three: telling them apart would turn the endpoint into an oracle for guessing keys.

Confirm the contact address

Registration hands back an API key and, alongside it, emailVerificationRequired set to true, a verification token, and the moment that token expires, 48 hours after it was issued. It also returns email, which is whether the confirmation mail actually went: sent, failed, or skipped when no mail endpoint is configured. Nothing you submit is published until the address is confirmed, and the flag is not advisory: it is the switch the catalog push reads.

The address has to match the site

The contact address has to be at the registrable domain of the website you are registering: [email protected] for https://example-esim.com. A mismatch is rejected at registration with the field named, not held for review.

The contact address has to sit at the registrable domain of the website you register, so mail.example-esim.com passes and a free mailbox does not. Read it as a consistency check rather than a proof: you supply both values, so it stops a careless registration and not a determined one. Proving the website itself is a separate step, below, and that is the one your plans wait on.

If your operations mail genuinely lives at another domain, this is the one case self-service hands to a person.

Confirming the address

Request

# the link in the confirmation email, followed by hand
curl 'https://esimradar.com/api/v1/providers/verify?token=6Yk2...'

# or, from a machine holding the token the register call returned
curl -X POST https://esimradar.com/api/v1/providers/verify \
  -H 'content-type: application/json' \
  -d '{ "token": "6Yk2..." }'

Confirming takes no API key. The token is the credential (256 bits, single use, and dead once it has been followed or replaced), because the person who reads the mailbox and the person who holds the key are often not the same person at a provider. Follow the link from the email and you get a page; call the same URL from anything that does not ask for HTML and you get JSON, chosen from the Accept header. A machine that already has the token from the registration response can POST it instead and never touch a mailbox.

Response

{
  "providerId": "9d2b41f6-8c07-4d2a-9a51-1f0e7c4b3a12",
  "slug": "example-esim",
  "verifiedAt": "2026-07-27T09:41:55Z",
  "heldPlans": 0
}

The response is small on purpose. heldPlans counts plans we already store and have deactivated, not plans you pushed while unverified, because an unverified push stores none. After a held push it reads 0, and that is the expected answer rather than a lost catalogue.

Confirming the address sets emailVerified and nothing else. It no longer publishes anything: proving your domain is what lets your plans go live, and your trustTier is a separate judgement that stays where it was. All three are reported separately because they answer different questions.

Asking for a new link

Request

curl -X POST https://esimradar.com/api/v1/providers/resend-verification \
  -H 'authorization: Bearer er_live_...'

Asking for a new link is the other way round: it needs the API key, because sending mail to a registered address is something only the account holder should be able to trigger. The response tells you which mailbox to check as a masked address, returns the new token, and says whether the mail actually went out rather than leaving you to assume it did. Issuing a new token kills the previous one immediately, so there is only ever one live link, and asking again within a few minutes comes back as rate_limited rather than queued.

If you push a catalog before confirming

Prove your domain

A self-registered catalog is listed once you have shown that you control the website you registered. Ask for a token, publish it, then tell us to look. The token stands for fourteen days rather than a couple of hours, because publishing a file usually goes through a release, and asking again before you have verified hands you the same token rather than invalidating the one you have already deployed.

The first way is a file: serve the token, and nothing else, at /.well-known/esimradar-challenge.txt on the site you registered. Trailing whitespace is fine. We compare the whole body, so a page that merely mentions the token does not count.

The second way is a meta tag on your home page, for hosts that will not serve arbitrary paths. It has to be in the HTML we receive, so a tag injected by JavaScript after load will not be found. We only read it from the site root, never from the response to the file above, because some platforms answer a missing path with their whole theme.

Both are tried, on the exact host you registered and then on its registrable domain, and we keep going until something matches. Some hosts serve the file and block the home page, others do the reverse, so stopping at the first miss would fail sites that are correctly set up.

If your CDN answers us with a challenge page we say so, with the URLs we tried and what came back, rather than telling you the file is missing. That failure is not something you can fix by editing the file, and sending you to look at it would waste your time.

Endpoints

EndpointAuthPurpose
POST /api/v1/providers/registerNoneCreate a provider account. Returns your provider id, slug and the one-time API key.
GET|POST /api/v1/providers/verifyVerification tokenConfirm the contact address. The token is the credential, so no API key: a browser gets a page, anything else gets JSON.
POST /api/v1/providers/resend-verificationAPI keyIssue a fresh confirmation link. The new one replaces whatever was live.
POST /api/v1/keys/rotateAPI keyIssue a new API key and retire every live one. Authenticated with the key it replaces.
GET /api/v1/meAPI keyYour profile, trust tier and affiliation status.
POST /api/v1/domain-challengeAPI keyGet the token that proves you control your registered website.
POST /api/v1/domain-verifyAPI keyCheck that the token is published, and list your catalog if it is.
PUT /api/v1/logoAPI keyTell us where your logo is. Required before a self-registered catalog is listed.
PUT /api/v1/catalogAPI keyFull catalog replace, validated as one run. Plans you leave out are deactivated, never deleted.
GET /api/v1/catalog/statusAPI keyLast run, per-plan listing status, and the most recent hundred validation issues with their reasons.
PATCH /api/v1/plans/{externalId}API keyUpdate one plan (its title, price, purchase URL or features) or deactivate it.
POST /api/v1/feedNot yetAPI keyRegister a JSON or CSV feed URL for us to pull daily instead of pushing.
GET /api/v1/schemaNoneThe catalog JSON Schema. Machine-readable, and the same definitions the MCP tools use.

One row above is not built yet. POST /api/v1/feed would let us pull a JSON or CSV feed from you on a schedule instead of you pushing one. The request schema is in the contract, the route is not written, and that is what the tag beside it means. Everything else in this table answers today.

Your account, as the site sees it

The profile endpoint is the one to reach for before writing to us about anything. It answers the three questions providers ask most: is the address confirmed, how many plans are actually listed, and when did we last hear from you.

Request

curl https://esimradar.com/api/v1/me \
  -H 'authorization: Bearer er_live_...'

Response

{
  "providerId": "9d2b41f6-8c07-4d2a-9a51-1f0e7c4b3a12",
  "slug": "example-esim",
  "name": "Example eSIM",
  "websiteUrl": "https://example-esim.com",
  "trustTier": "unverified",
  "affiliationStatus": "none",
  "featuring": {
    "eligible": false,
    "reason": "affiliation_missing"
  },
  "listing": {
    "listed": true,
    "reason": "listed",
    "waitingOn": "you",
    "since": null,
    "detail": "Your catalog is listed. Plans appear on the site at the next snapshot rebuild."
  },
  "emailVerified": true,
  "listedPlanCount": 409,
  "lastSyncAt": "2026-07-27T10:02:31Z"
}

featuring is the part worth reading closely. eligible says whether this account can appear on the promoted surfaces, and reason names the single thing standing in the way. affiliation_missing means there is no affiliate relationship at all. network_not_supported means there is a live one, on a network the promoted surfaces do not run on. tier_too_low means the relationship is fine and the trust tier is not. Being listed depends on none of them.

Submitting a catalog

Up to 10,000 plans in one submission, as a full replace. Send prices in the currency you quote: we convert to USD in the pipeline and keep your own figures alongside. A currency we hold no rate for is reported as a rejected plan rather than guessed at, because treating 1 THB as one dollar would put a plan into the value ranking at thirty times its real price.

Request

curl -X PUT https://esimradar.com/api/v1/catalog \
  -H 'authorization: Bearer er_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "catalogVersion": "2026-07-27T00:00:00Z",
    "plans": [
      {
        "externalId": "jp-10gb-30d",
        "title": "Japan 10 GB / 30 days",
        "coverageKind": "country",
        "coverage": [{ "countryIso2": "JP" }],
        "dataGb": 10,
        "isUnlimited": false,
        "durationDays": 30,
        "price": { "currency": "USD", "amount": "12.90" },
        "features": { "hotspot": true, "fiveG": true, "topup": true },
        "purchaseUrl": "https://example-esim.com/plans/jp-10gb-30d"
      }
    ]
  }'

A full replace deactivates plans you omit; it never deletes them, because price history has to survive. Coverage takes exactly one country for a country plan, and up to 250 for a regional or global one.

Two shapes the first example does not show

An unlimited plan sends dataGb: null together with isUnlimited: true. The pairing is enforced rather than tolerated: a limited plan without dataGb and an unlimited plan with one are both refused at parse time, with dataGb named as the path. fupDailyMb is optional, and leaving it off an unlimited plan is a warning rather than a rejection. A regional plan carries up to 250 coverage entries, where a country plan carries exactly one.

Request

{
  "plans": [
    {
      "externalId": "jp-unlimited-7d",
      "title": "Japan Unlimited / 7 days",
      "coverageKind": "country",
      "coverage": [{ "countryIso2": "JP" }],
      "dataGb": null,
      "isUnlimited": true,
      "fupDailyMb": 2000,
      "durationDays": 7,
      "price": { "currency": "JPY", "amount": "2980" },
      "prices": { "USD": "19.50" },
      "purchaseUrl": "https://example-esim.com/plans/jp-unlimited-7d"
    },
    {
      "externalId": "eu-20gb-30d",
      "title": "Europe 20 GB / 30 days",
      "coverageKind": "regional",
      "coverage": [
        { "countryIso2": "FR" },
        { "countryIso2": "DE" },
        { "countryIso2": "ES", "externalIdOverride": "eu-20gb-30d-es" }
      ],
      "dataGb": 20,
      "isUnlimited": false,
      "durationDays": 30,
      "price": { "currency": "EUR", "amount": "24.00" },
      "features": { "hotspot": true, "topup": false, "activation": "first_usage" },
      "purchaseUrl": "https://example-esim.com/plans/eu-20gb-30d",
      "isActive": true
    }
  ]
}

What comes back

Response

{
  "runId": "c41a7e50-3b62-4f18-9d07-2a5be814c9f3",
  "status": "partial",
  "stats": {
    "received": 412,
    "accepted": 409,
    "rejected": 3,
    "added": 0,
    "updated": 409,
    "deactivated": 7
  },
  "resumeToken": null,
  "issues": [
    {
      "severity": "reject",
      "code": "PRICE_ZERO",
      "planExternalId": "th-3gb-8d",
      "message": "price.amount is 0"
    },
    {
      "severity": "warn",
      "code": "URL_DOMAIN_MISMATCH",
      "planExternalId": "jp-10gb-30d",
      "message": "purchaseUrl is not on example-esim.com"
    }
  ],
  "listing": {
    "listed": true,
    "reason": "listed",
    "waitingOn": "you",
    "since": null,
    "detail": "Your catalog is listed. Plans appear on the site at the next snapshot rebuild."
  }
}

A run id, a status, the counts, and up to a hundred validation issues. Two of the counts need reading carefully. added is always zero: the upsert cannot tell an insert from an update, and reporting a number we did not measure would be worse than reporting none. deactivated is how many plans were listed and are absent from this submission, which on a first push is zero and on a mistaken one is your whole catalogue.

A status of quarantined comes back as HTTP 202 rather than 200. It means a gate is closed, and the listing block on the same response names which: an unproven domain, a logo we have not vendored yet, or a catalog that had gone stale before this push. Read a 2xx as received and recorded rather than as listed: publishing happens when the snapshot rebuild runs, not while your request is open.

A hundred is a ceiling and not a total. The status endpoint returns a hundred as well, drawn from every run rather than only your last, and we store the first two hundred issues a submission produces. Past that they are not recorded anywhere. If a push comes back with a hundred issues, read that as at least a hundred, fix what you can see, and push again to find the rest.

resumeToken is null on every response today, and there is nowhere to send one back: the request schema has no field for it. A push either finishes inside the function budget or it fails. The field is in the response shape because the checkpointed path is planned and a client that reads it now will not have to change when tokens start arriving; until then there is nothing to resume, and a submission near the ceiling is one call that either lands or does not.

Retrying after a timeout is safe, but not for the reason the schema implies. catalogVersion is accepted and nothing reads it yet, so re-sending a version we have already seen is not a no-op: it runs the whole submission again. What makes that harmless is that a full replace is idempotent by construction: the same body twice lands on the same catalogue. It costs you a second run and a second unit of your hourly catalog budget, not a damaged listing.

Keeping the listing

A catalog you push has to be pushed again. Go 30 days without a submission we store and your plans come off the site: they stay in the database, they keep their price history, and they are simply not shown until the next successful push. The rule is only for providers who onboarded themselves through this API. A feed we pull is ours to keep current; a catalog you send is yours.

The clock restarts when a submission is stored, not when one arrives, so a push we reject in full moves nothing. Once it has run out, listing.reason reads catalog_stale on every response, and one successful push clears it: the rebuild is queued by the gate reopening and there is nothing else to send. Nobody warns you first, so read /api/v1/me when you want to know where you stand rather than waiting to be told.

The plan object, field by field

One row per field of a submitted plan. Anything marked required has to be present on every plan in the array, and anything with a default is filled in when you leave the field out, which matters most for isActive.

FieldTypeRequiredNotes
externalIdstringAlwaysYour own stable product id, unique within your catalogue and up to 200 characters. It is the address a single-plan update uses and the key a full replace matches on, so it has to survive a price change and a re-push. Never reuse one for a different product.
titlestringAlwaysWhat you call the plan, up to 200 characters. It is rendered as you send it, so keep the data allowance and the duration in it rather than marketing copy.
coverageKind"country" | "regional" | "global"AlwaysDecides how many coverage entries are allowed and how the plan is grouped on the site. A value of country takes exactly one entry.
coverage{ countryIso2, externalIdOverride? }[]AlwaysOne entry per covered country, up to 250, each an upper-case ISO 3166-1 alpha-2 code. Add externalIdOverride to an entry when you sell the same plan under a different product id in that market.
dataGbnumber | nullAlwaysGigabytes for the whole duration, or null when the plan is unlimited. Never send 0 to mean unlimited: set isUnlimited instead.
isUnlimitedbooleanDefaults to falseSend true for an unlimited plan, and send dataGb as null alongside it. The two are checked against each other before anything else runs.
fupDailyMbinteger | nullMay be omittedThe daily fair-use allowance in megabytes, where the plan has one. Leaving it off an unlimited plan still lists the plan and records an UNLIMITED_WITHOUT_FUP warning, because readers compare unlimited plans by their fair-use figure.
durationDaysintegerAlwaysWhole days of validity. Set dailyPlan in features when the allowance is per day rather than for the whole period.
price{ currency, amount }AlwaysThe price as you quote it: an ISO 4217 currency and a decimal string. We convert to USD for ranking and keep your own figure alongside. A currency we hold no rate for is a rejected plan rather than a guess.
pricesRecord<currency, amount>May be omittedFurther currencies you quote for the same plan, as currency to amount. Sending price rewrites this map with the quoted currency folded in, so send both together when you quote several.
featuresobjectMay be omittedOptional flags: hotspot, voice, sms, phoneNumber, ekyc, topup, fiveG, dailyPlan, subscription, an activation of install or first_usage, and networks. A key we do not recognise is refused rather than ignored.
purchaseUrlhttps URLAlwaysWhere a reader buys this exact plan, over https. A URL that does not resolve is a rejection. A URL on a host other than your registered site is a warning and still lists.
isActivebooleanDefaults to trueLeave it out to publish. Send false to keep the plan in your submission while taking it off the site, which is also what a single-plan update does.

The authoritative version of this table is the JSON Schema, which carries the exact bounds on every field. This is the readable copy, and a test holds its required column to the schema so the two cannot disagree.

Updating a single plan

A single-plan update exists for the change that does not deserve a whole catalogue: a price moved, a link changed, one plan came off sale. The body is a partial, and an empty one is refused. The lookup is scoped to your account, so an external id can only ever address your own plan.

Request

curl -X PATCH https://esimradar.com/api/v1/plans/jp-10gb-30d \
  -H 'authorization: Bearer er_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "price": { "currency": "USD", "amount": "11.40" },
    "purchaseUrl": "https://example-esim.com/plans/jp-10gb-30d?src=radar"
  }'

Response

{
  "ok": true,
  "planId": "f07c9d21-5ea4-4b83-8c16-93d2f4a60b57"
}

The response is ok and the plan id we hold, and nothing else. Sending price rewrites the whole quoted-price map with that currency folded in, so a provider quoting several currencies sends prices alongside it rather than expecting the others to survive. A currency we have no USD rate for is refused here exactly as it is in a full push.

Taking one plan off the site

Request

curl -X PATCH https://esimradar.com/api/v1/plans/jp-10gb-30d \
  -H 'authorization: Bearer er_live_...' \
  -H 'content-type: application/json' \
  -d '{ "isActive": false }'

Sending isActive as false deactivates the plan without deleting it, which is the same thing a full replace does to a plan you omit. Price history survives either way. Send true to put it back.

When the id is not one of yours

Response

HTTP/1.1 404 Not Found

{
  "error": {
    "code": "not_found",
    "message": "No plan with externalId \"jp-10gb-30d\"."
  }
}

An external id this account has never submitted is a 404 rather than an empty success. It is also what you get for another provider's plan, because the lookup is scoped by provider before it is scoped by id.

Reading back what is listed

The status endpoint is the review process. It reports the last run with its counts, the most recent hundred validation issues across every run, and a page of your plans with a listed flag and a reason on each one.

Request

curl https://esimradar.com/api/v1/catalog/status \
  -H 'authorization: Bearer er_live_...'

# the next page, using the cursor the previous response returned
curl 'https://esimradar.com/api/v1/catalog/status?cursor=jp-10gb-30d' \
  -H 'authorization: Bearer er_live_...'

Response

{
  "lastRun": {
    "runId": "c41a7e50-3b62-4f18-9d07-2a5be814c9f3",
    "trigger": "push_api",
    "status": "partial",
    "startedAt": "2026-07-27T10:01:58Z",
    "finishedAt": "2026-07-27T10:02:31Z",
    "stats": { "received": 412, "accepted": 409, "rejected": 3, "deactivated": 7 }
  },
  "issues": [
    {
      "severity": "reject",
      "code": "PRICE_ZERO",
      "planExternalId": "th-3gb-8d",
      "message": "price.amount is 0"
    }
  ],
  "plans": {
    "items": [
      {
        "externalId": "eu-20gb-30d",
        "listed": true,
        "reason": "ok",
        "planId": "1b8e30af-7c25-4d69-90fa-6e4c17d2b085",
        "lastSeenAt": "2026-07-27T10:02:29Z"
      },
      {
        "externalId": "jp-10gb-30d",
        "listed": true,
        "reason": "ok",
        "planId": "f07c9d21-5ea4-4b83-8c16-93d2f4a60b57",
        "lastSeenAt": "2026-07-27T10:02:29Z"
      }
    ],
    "nextCursor": "jp-10gb-30d"
  }
}

plans is a keyset page rather than a list. Pass the nextCursor you were given back as the cursor query parameter to get the next page, and keep going until nextCursor is null. That null is the only signal that you have seen everything: a short page is not one, and neither is an empty one.

What a reason means

ok is listed. rejected is a plan a validation issue dropped, and the issue carrying the same external id says which. inactive is a plan you deactivated or left out of a later push. awaiting_email_verification is the whole account waiting on a confirmed address rather than anything about that plan. quarantined and unverified_cap are reserved, so treat a plan carrying one as unlisted rather than as an error.

Validation and why a plan was rejected

Every submission is checked for schema errors, duplicate ids, implausible prices, durations and data allowances, and whether each purchase URL resolves. A rejected plan is dropped from the submission with a reason you can read back. The pipeline is the reviewer, so nothing waits on us noticing.

The codes split two ways and the difference decides whether you have a plan. A reject drops that plan and lists the rest: a duplicate externalId, no usable coverage, a price of zero or wildly out of band, an implausible duration or data allowance, or a purchase URL that does not resolve. A warn lists the plan and records the concern: a country we do not recognise, an unlimited plan with no fair-use figure, a price that is merely unusual, and, worth knowing before you rely on it, a purchase URL on a host other than your registered website. That last one is recorded and does not stop the plan going live, so treat matching your own domain as your job rather than ours.

Issue codes you may see

When the call itself fails

Every failure arrives in one envelope: an error object carrying a code, a message written for you rather than for us, and, when a schema check is what failed, a details array naming the field path that failed and what was wrong with it. There is no support queue behind this API, so the message is meant to be enough on its own, and it never contains a table name or a constraint.

Response

HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "invalid_request",
    "message": "The request body does not match the published schema (GET /api/v1/schema).",
    "details": [
      { "path": "plans.0.price.amount", "message": "must be a non-negative decimal string" },
      { "path": "plans.4.coverage", "message": "coverageKind=\"country\" takes exactly one coverage entry" }
    ]
  }
}
CodeHTTP
unauthorized401
forbidden403
not_found404
invalid_request400
rate_limited429
payload_too_large413
conflict409
internal_error500

These eight codes are not the issue codes above, and the two are easy to confuse because both come back as strings from the same API. An issue code explains why one plan was dropped inside a submission that otherwise worked: the call returned 200, the rest of your catalogue is listed, and the issue is a note about one row. An error code means the call did not happen at all: nothing was read, nothing was written, and the status line says so.

The two internal shapes

Response

HTTP/1.1 500 Internal Server Error

{
  "error": {
    "code": "internal_error",
    "message": "Something went wrong on our side."
  }
}

HTTP/1.1 503 Service Unavailable

{
  "error": {
    "code": "internal_error",
    "message": "The provider API is not configured yet.",
    "detail": "SUPABASE_SERVICE_ROLE_KEY is not set"
  }
}

internal_error arrives two ways, and the difference tells you whether to retry. A 500 is a genuine fault on our side. Its real message names tables and columns, so it is logged here and replaced with a generic sentence, and there is nothing in the body to act on beyond trying again.

A 503 is the other one: the API is reachable but not configured, which is ours to fix rather than yours to debug. There the response says so plainly and adds a detail string naming the misconfiguration. A detail is a single string; the details array is what a schema failure carries.

Rate limits

A fixed one-hour window per scope, counted in Postgres rather than in memory. An in-process counter on serverless resets on every cold start, which reports that it is protecting something while protecting nothing. The schema endpoint is not metered, and neither is the MCP endpoint.

ScopePer hourCounted againstWhat spends it
register5Your IP addressRegistering, and following a confirmation link. Both run before anyone has a key.
catalog60Your provider accountA full catalog replace. One call, however many plans.
plans600Your provider accountSingle-plan updates, which is the high-frequency path.
read1,200Your provider accountYour profile, catalog status, and asking for a new confirmation link.
keys10Your provider accountRotating your API key. Rare, and every call retires a credential.
domain20Your provider accountChecking whether your challenge token is published.
domainTarget20The website being claimedEvery provider claiming one website, together, so a claimed site is never flooded on our behalf.
domainChallenge10Your provider accountIssuing or rotating a domain challenge token.

Reading the budget off a response

Response headers

HTTP/1.1 200 OK
cache-control: no-store
x-ratelimit-remaining: 3
x-ratelimit-reset: 2026-07-27T11:00:00Z

Every JSON response from an authenticated route carries x-ratelimit-remaining and x-ratelimit-reset, on the calls that succeed as well as the one that is refused. A client that reads them slows down before it is refused instead of discovering the ceiling by hitting it.

Response

HTTP/1.1 429 Too Many Requests
x-ratelimit-remaining: 0
x-ratelimit-reset: 2026-07-27T11:00:00Z

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded for catalog. It resets at 2026-07-27T11:00:00Z."
  }
}

Spending a budget returns 429 with the code rate_limited and the reset time in both the header and the message. Wait for that reset rather than retrying straight away: the window is fixed rather than sliding, so nothing frees up before it.

The limiter fails open. If it cannot reach its own database your request is allowed through, because a guard rail that takes the API down when a dependency degrades has turned a slow hour into an outage. It is a guard rail, not the service.

A refused call still spends its unit. The limiter runs before the body is parsed, so a registration rejected for a mismatched contact domain costs the same as one that succeeds: five attempts an hour is five attempts, not five accounts. Read x-ratelimit-remaining on the way past rather than counting your own successes.

Best practices

Nine rules, every one of them either a mistake somebody has already made against this API or a consequence stated above that is easy to reach too late.

MCP server

The same service functions over a streamable-HTTP MCP endpoint, so your own agent can onboard end to end. One implementation, two transports, so the REST route and the MCP tool cannot drift apart.

Client configuration

{
  "mcpServers": {
    "esimradar": {
      "type": "http",
      "url": "https://esimradar.com/api/mcp",
      "headers": { "authorization": "Bearer er_live_..." }
    }
  }
}

All six tools are live. Five of them call the same service functions the REST routes call, so a catalog pushed by an agent goes through the validation a curl would hit, and neither transport can drift from the other on what it accepts.

Three differences are worth knowing before you build on it. get_validation_issues is the one tool with no REST equivalent, and it returns rows in the database's own spelling rather than the contract's. The MCP endpoint is not rate limited, so the budgets above do not apply to it. And registering over MCP sends no confirmation mail, so take the token from the response.

Tools exposed

Logos and affiliate links

Neither the REST API nor the MCP server accepts a logo or an affiliate link today, and nothing quietly ignores one: the endpoint does not exist. The format does. It is a schema in the same shared contract the rest of this page is built from, and it is what that endpoint will validate against when it lands. A submission is one document, and both halves of it are optional as long as one is present: a provider with no affiliate programme still gets their logo on the site.

Document

{
  "providerSlug": "example-esim",
  "websiteUrl": "https://example-esim.com",
  "logo": {
    "url": "https://example-esim.com/brand/logo.png",
    "format": "image/png",
    "width": 1200,
    "height": 354,
    "transparentBackground": true
  },
  "affiliate": {
    "network": "impact",
    "linkTemplate": "https://example-esim.pxf.io/c/1234/5678?u={{deep_link}}&subId1={{click_id}}"
  },
  "contact": {
    "name": "Partnerships",
    "email": "[email protected]"
  }
}

The logo is a URL we fetch, never an upload, never a data: URI, and never hotlinked afterwards. Send a transparent PNG or an SVG. A raster file has to be at least 400 pixels wide, because the set it joins is normalised to a 400×118 canvas and anything narrower would be enlarged into it rather than reduced; an SVG has no pixel minimum. The lockup has to be horizontal, between 1.5:1 and 6:1, because every provider gets the same 92×27 box on a plan row and a square mark shrinks inside it until it is smaller than the letter tile it replaced. Declare the format, width and height you are sending: we check the file against what you declared.

The affiliate link is a template rather than a URL. {{deep_link}} is the plan's own purchase URL and {{click_id}} is the click identifier; we percent-encode both and substitute them at click time, which means the deep link has to sit in your template as a parameter value and not as the URL itself. A template missing {{deep_link}} is rejected outright: it would still deliver the visitor to a working checkout, which is exactly why nobody would notice the attribution was gone. Any other token in double braces reaches your network verbatim, so it is rejected too.

Logo hosts we accept besides your own domain

A subdomain of your own site works as well, and a site builder's CDN counts as your site. A paste host or a file locker does not: the URL is also the only evidence in a submission that you are entitled to hand us the mark.

We fetch the file once and re-encode it ourselves, as AVIF and WebP at 240×71, served from our own domain with immutable cache headers. Nothing on the site ever renders the URL you sent, so your CDN sees one request from us and none from our visitors. The template is stored against your provider record and applied per click behind our own redirect, so it never reaches the HTML of a country page. Until we have artwork for you, your name renders as a letter tile: a deliberate fallback, and it costs you nothing in ranking.

What gets rejected

There is no endpoint for this yet, so there is nothing to build against: prepare the asset, not the integration. What the schema buys you in the meantime is that an asset which satisfies it passes on the first attempt, rather than in the third round of an email thread.

What the API cannot buy you

Ranking. Plans are ordered by price per GB computed from what you send us, and no field in this API changes that. The gradient "top pick" treatment, the "Our picks" shelf and the "Recommended" tab above a country board need a working affiliate relationship, and they are the only three things that do. Everything else is identical whether we have a commercial relationship or not, and the board's other three sorts list your plans either way.

Want the non-technical version? Get listed

The API and the MCP server are both covered by our provider terms