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.
Register and authenticate
Registration is open and returns an API key once — we store only its hash, so it cannot be read back to you, and there is no rotation endpoint yet either. Keep it where you would keep any other secret. Send it as a bearer token on every other call.
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]"
}'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 contact address has to be at the registrable domain of the website you are registering — [email protected] for https://example-esim.com — and a mismatch is rejected at registration with the field named, not held for review. This is the check that stops someone registering a provider's name with a free mail address: controlling the mailbox at a domain is as close to speaking for a company as an unattended endpoint can get. Registrable domain means the labels that identify one organisation, so mail.example-esim.com passes and example-esim.co.uk does not. If your operations mail genuinely lives at another domain, this is the one case self-service hands to a person.
Confirming the address sets emailVerified and nothing else. Your trustTier is a separate judgement and stays where it was — a verified address is what lets your plans publish, not what promotes you. The two are reported separately by the profile endpoint for that reason, and only the tier has any bearing on featuring.
# 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.
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
- The submission is validated and the run is recorded, so every issue it found — an unreachable URL, an implausible price — is waiting for you and you can fix it before you verify rather than after. What comes back is HTTP 202 with a status of quarantined, accepted at zero, and the run marked awaiting_email_verification. The plans themselves are not stored: a held push keeps the verdict, not the catalogue.
- The status endpoint reports the run, not a held catalogue. Because nothing was stored, its plans array is empty and stays empty until a push lands after verification — the per-plan awaiting_email_verification reason describes plans we already hold, which at this point is none. The signal to read is lastRun.status of quarantined. An empty plans array with a quarantined last run means held, not lost.
- Verifying publishes nothing on its own, and heldPlans in the verify response does not count what you pushed — it counts plans already stored and inactive, which after a held push is zero. Nothing carries over: confirm the address, then send the catalogue again. The second push is the one that lists.
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
| POST /api/v1/providers/register | None | Create a provider account. Returns your provider id, slug and the one-time API key. |
| GET|POST /api/v1/providers/verify | Verification token | Confirm 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-verification | API key | Issue a fresh confirmation link. The new one replaces whatever was live. |
| GET /api/v1/me | API key | Your profile, trust tier and affiliation status. |
| PUT /api/v1/catalog | API key | Full catalog replace, validated as one run. Plans you leave out are deactivated, never deleted. |
| GET /api/v1/catalog/status | API key | Last run, per-plan listing status, and the most recent hundred validation issues with their reasons. |
| PATCH /api/v1/plans/{externalId} | API key | Update one plan — its title, price, purchase URL or features — or deactivate it. |
| POST /api/v1/feedNot yet | API key | Register a JSON or CSV feed URL for us to pull daily instead of pushing. |
| GET /api/v1/schema | None | The 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.
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 — and 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.
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.
What comes back
{
"runId": "9d2b41f6-8c07-4d2a-9a51-1f0e7c4b3a12",
"status": "partial",
"stats": {
"received": 412,
"accepted": 409,
"rejected": 3,
"added": 0,
"updated": 409,
"deactivated": 7
},
"resumeToken": null,
"issues": [
{
"severity": "reject",
"code": "URL_DOMAIN_MISMATCH",
"planExternalId": "jp-10gb-30d",
"message": "purchaseUrl is not on example-esim.com"
}
]
}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 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.
A status of quarantined comes back as HTTP 202 rather than 200. Today it means one thing — the contact address is not confirmed, so the catalogue was validated and held. Either way, read a 2xx as received and recorded rather than as listed: publishing happens when the snapshot rebuild runs, not while your request is open.
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.
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.
curl https://esimradar.com/api/v1/catalog/status \
-H 'authorization: Bearer er_live_...'Issue codes you may see
- SCHEMA_INVALID
- DUPLICATE_EXTERNAL_ID
- UNKNOWN_COUNTRY
- MISSING_COVERAGE
- PRICE_OUTLIER
- PRICE_ZERO
- DURATION_IMPLAUSIBLE
- DATA_IMPLAUSIBLE
- URL_UNREACHABLE
- URL_DOMAIN_MISMATCH
- CURRENCY_UNSUPPORTED
- UNLIMITED_WITHOUT_FUP
- CATALOG_SHRANK
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.
{
"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" }
]
}
}| Code | HTTP |
|---|---|
| unauthorized | 401 |
| forbidden | 403 |
| not_found | 404 |
| invalid_request | 400 |
| rate_limited | 429 |
| payload_too_large | 413 |
| conflict | 409 |
| internal_error | 500 |
internal_error is the exception, and it arrives two ways. A genuine fault comes from inside, where a Postgres error names tables and columns, so it is logged on our side and replaced with a generic sentence — that one you never see the text of. The other is a 503 rather than a 500: the API is reachable but not configured, and there the response says so plainly and adds a detail string naming which misconfiguration, because it is ours to fix and not yours to debug. A detail is a single string; the details array is what a schema failure carries.
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.
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. Every JSON response from an authenticated route carries x-ratelimit-remaining and x-ratelimit-reset, so a client can slow down before it is refused instead of discovering the ceiling by hitting it; spending a budget returns 429 with the code rate_limited and the reset time in the message. The schema endpoint is not metered, and the MCP endpoint is not metered either.
| Scope | Per hour | Counted against | What spends it |
|---|---|---|---|
| register | 5 | Your IP address | Registering, and following a confirmation link. Both run before anyone has a key. |
| catalog | 60 | Your provider account | A full catalog replace. One call, however many plans. |
| plans | 600 | Your provider account | Single-plan updates, which is the high-frequency path. |
| read | 1,200 | Your provider account | Your profile, catalog status, and asking for a new confirmation link. |
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.
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 — the REST route and the MCP tool cannot drift apart.
{
"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. Two 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; and the MCP endpoint is not rate limited, so the budgets below do not apply to it. Registering over MCP also sends no confirmation email — take the token from the response.
Tools exposed
- register_provider
- submit_catalog
- update_plan
- get_listing_status
- get_validation_issues
- get_catalog_schema
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.
{
"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 and 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
- cdn.shopify.com
- res.cloudinary.com
- images.squarespace-cdn.com
- cdn.prod.website-files.com
- uploads-ssl.webflow.com
- static.wixstatic.com
- imgix.net
- b-cdn.net
- cloudfront.net
- akamaized.net
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 — 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
- A logo we cannot fetch, or cannot attribute to you: an http:// URL, a data: URI with the file inlined, or a host that is neither yours nor one of the CDNs above.
- A JPEG, which has no transparency and so arrives as a mark on an opaque rectangle. A WebP, because it is one of the two formats we output — re-encoding a lossy file preserves its artefacts as faithfully as its letterforms.
- A raster narrower than 400 pixels, usually a favicon someone reached for because it was the only file to hand, or a lockup outside 1.5:1 to 6:1, which covers every square and stacked mark.
- A logo declared with a solid background, and one whose file turns out to disagree with the format or the dimensions declared for it.
- A template without {{deep_link}}, one carrying a placeholder we do not substitute, or one that does not resolve to a single https:// URL once the deep link is encoded into it.
- A slug that is not yours. The website in the submission has to match the one the slug is registered to — a template is the path a provider's clicks and their revenue take, and this is the door to it.
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
