Part one
Get started
From nothing to priced offers in one afternoon. Every snippet below runs as it is once you drop your own key in. One key opens every endpoint on this page.
Step 0
What you need
| Item | Value |
|---|---|
| Base URL | https://atlas.hellosafe.com |
| Key id | ak_test_... in sandbox, ak_live_... in production. One key, every endpoint: pricing, links and Coach. |
| Signing secret | Shown once, when you create the key. Server side only. |
| Runtime | Anything that can compute an HMAC-SHA256. Every language can. |
This is a server to server API. There is no CORS header on any response, on purpose: a signing secret that reaches a browser is a secret you have to revoke.
Step 1
Get your sandbox key
Create a partner account, open API in the dashboard, and click Create my test key. You get a key id and a signing secret on the spot. Nobody reviews anything, because a sandbox key is inert: it answers from fixed sample data, calls no insurer, creates no real quote and credits no sale.
Sandbox quota: 200 prices and 50 links per day, and 60 calls per minute whatever the endpoint. The daily figure is what an integration day needs; the per minute ceiling is what stops a runaway loop.
Step 2
Sign a request
Three headers on every call:
| Header | Value |
|---|---|
x-atlas-key-id | Your key id. |
x-atlas-timestamp | Now, in unix seconds. Rejected beyond 5 minutes off (replay guard), so keep your clock synced. |
x-atlas-signature | v2= followed by the hex HMAC-SHA256 of the message below, keyed with your signing secret. |
`${ts}.${METHOD}.${pathname}.${rawBody}`
e.g. 1787654321.POST./api/v1/travel/quotes.{"trip":{...},"language":"en"} Step 3
Your first priced quote
One call describes one trip: dates, destinations, ages. You get the offers that cover it, sorted cheapest first, each with its price and the detail of every guarantee.
Node.js
import { createHmac } from "node:crypto";
const KEY_ID = process.env.ATLAS_KEY_ID; // ak_test_... (sandbox)
const SECRET = process.env.ATLAS_SIGNING_SECRET; // shown once, at creation
const body = JSON.stringify({
trip: {
intent: "forTourism",
startDate: "2026-09-10",
endDate: "2026-09-24",
countryResidence: "FR",
arrivalCountries: ["TH"],
travellers: [{ age: 32 }]
},
language: "en"
});
const ts = Math.floor(Date.now() / 1000).toString();
const sig = "v2=" + createHmac("sha256", SECRET)
.update(`${ts}.POST./api/v1/travel/quotes.${body}`)
.digest("hex");
const res = await fetch("https://atlas.hellosafe.com/api/v1/travel/quotes", {
method: "POST",
headers: {
"content-type": "application/json",
"x-atlas-key-id": KEY_ID,
"x-atlas-timestamp": ts,
"x-atlas-signature": sig
},
body
});
const { sessionId, offers } = await res.json();
console.log(offers.length, "offers, cheapest:", offers[0].price.amount);
// Keep sessionId next to what you display: it is THIS traveller's quoting
// session, and /links will require it. cURL
KEY_ID="ak_test_..."
SECRET="sk_test_..."
BODY='{"trip":{"intent":"forTourism","startDate":"2026-09-10","endDate":"2026-09-24","countryResidence":"FR","arrivalCountries":["TH"],"travellers":[{"age":32}]},"language":"en"}'
TS=$(date +%s)
SIG="v2=$(printf '%s.POST./api/v1/travel/quotes.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
curl -s https://atlas.hellosafe.com/api/v1/travel/quotes \
-H "content-type: application/json" \
-H "x-atlas-key-id: $KEY_ID" \
-H "x-atlas-timestamp: $TS" \
-H "x-atlas-signature: $SIG" \
-d "$BODY" PHP
<?php
$keyId = getenv('ATLAS_KEY_ID');
$secret = getenv('ATLAS_SIGNING_SECRET');
$path = '/api/v1/travel/quotes';
$body = json_encode([
'trip' => [
'intent' => 'forTourism',
'startDate' => '2026-09-10',
'endDate' => '2026-09-24',
'countryResidence' => 'FR',
'arrivalCountries' => ['TH'],
'travellers' => [['age' => 32]],
],
'language' => 'en',
]);
$ts = (string) time();
$sig = 'v2=' . hash_hmac('sha256', "$ts.POST.$path.$body", $secret);
$ch = curl_init('https://atlas.hellosafe.com' . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body, // the SAME string you signed
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'content-type: application/json',
"x-atlas-key-id: $keyId",
"x-atlas-timestamp: $ts",
"x-atlas-signature: $sig",
],
]);
$response = json_decode(curl_exec($ch), true);
echo count($response['offers']), " offers\n"; What comes back
{
"ok": true,
"mode": "sandbox",
"sessionId": "qs_1f2e3d4c5b6a79880919a2b3c4d5e6f7.9a8b…",
"offers": [
{
"id": 9001,
"name": "Sandbox Essential",
"plan": "Essential",
"insurer": { "name": "Sandbox Assurance", "logoUrl": null },
"price": {
"amount": 38.40,
"amountInCents": 3840,
"currency": "EUR",
"insurerAmount": 38.40,
"insurerCurrency": "EUR",
"isStartingPrice": false,
"period": null
},
"guaranteeCurrency": "EUR",
"guarantees": {
"hospitalFeesAbroad": { "state": "value", "value": 100000 },
"rapatriationAssistance": { "state": "actual_costs" },
"suitcaseInsurance": { "state": "value", "value": 1000 },
"cancelTrip": { "state": "not_available" }
},
"highlights": { "included": ["..."], "excluded": ["..."] },
"documents": { "cgvUrl": null, "ipidUrl": null },
"position": 1
}
],
"unpricedCount": 0,
"redirectOnlyCount": 0,
"trip": { "...": "the trip we priced, normalised" },
"quote": {
"mode": "sandbox",
"days": 15,
"travellers": 1,
"expiresAt": "2026-09-01T12:30:00.000Z"
},
"meta": { "apiVersion": "1.0.0", "language": "en", "notice": "Sandbox data..." }
} Full field by field description in the reference below. The live response has the same shape, with real insurers and real premiums.
insurer.logoUrl is the offer's pictogram now, not the insurer's
logo. This changed in 2.0.0. Our own comparator stopped showing
insurer logos on brokerage offers: each offer carries its own icon, a
pictogram on a tile. The API follows, and it follows in the field you are
already reading, so your page shows the new image with no change on your
side. The name is a misnomer and we kept it deliberately, so that the
switch reaches you without work. The image is an SVG served by the very
function that draws it on hellosafe.com, so what you display is what our
own visitors see. Add ?size=16..512 if your layout wants
another size: it is vector, so any size stays sharp.
The insurer is still named. insurer.name is
untouched, so "underwritten by Chapka" keeps working, which is what your
customer needs to know. What is no longer published, for an offer that has
an icon, is that insurer's logo.
iconUrl tells you which of the two you got. It carries the
same URL as logoUrl when the offer has an icon, and is
absent, never null and never an empty string, when it has none, in
which case logoUrl fell back to the insurer's logo. Reading
logoUrl alone can no longer tell those apart. Every offer we
serve carries an icon today, so the fallback is not hit in practice, but
the sandbox's cheapest plan deliberately has none, which is why you cannot
see the field in the response above: code that path before you go live
rather than after.
redirectOnlyCount is the offers we deliberately do not return. The catalogue also carries offers sold by sending the traveller to the insurer's own site. Those cannot be attributed to you: the sale leaves HelloSafe, no subscription exists, the conversion postback never fires and you would not be paid. They are also a dead end for the traveller, who would have to key the whole trip in again. The count is normally 0: they are excluded before we ever see them. A non-zero value means one slipped past that exclusion and our own filter caught it. Every offer you DO get back is one you can be paid on.
Step 4
Turn a chosen offer into a sale
When the traveller picks an offer, ask for a link with the
sessionId your quote came with and the offerId
they clicked. It creates the quote on the HelloSafe side with your
attribution baked in, and returns the URL you hand over: the traveller
lands on the presubscribe form with their offer selected at the live
premium, finishes and pays in the HelloSafe funnel, and the commission is
yours.
const path = "/api/v1/travel/links";
const body = JSON.stringify({
sessionId, // from the /quotes response: the traveller's session
trip, // same trip you priced
language: "en",
offerId: chosen.id // the offer they clicked, from the /quotes offers
});
const ts = Math.floor(Date.now() / 1000).toString();
const sig = "v2=" + createHmac("sha256", SECRET)
.update(`${ts}.POST.${path}.${body}`)
.digest("hex");
const res = await fetch("https://atlas.hellosafe.com" + path, {
method: "POST",
headers: {
"content-type": "application/json",
"x-atlas-key-id": KEY_ID,
"x-atlas-timestamp": ts,
"x-atlas-signature": sig
},
body
});
const { url, subscriptionId } = await res.json();
// -> https://hellosafe.com/travel-insurance/app/subscribe
// ?subscription_id=...&offerId=...
// Hand that URL to the traveller: they land on the presubscribe form with
// their offer selected, premium re-rated live. The sale is credited to you.
// Click another offer? Call again with the SAME sessionId: same subscription,
// new offer in the URL. Trip edited? Same sessionId: updated in place. /links again
with the same sessionId replays the same subscription with the
newly clicked offer in the URL, and a trip edit (echo the
sessionId into /quotes first) updates it in place.
Two travellers can never share one, even on identical trips: each
/quotes response is its own session. And once your traveller
presubscribes or pays, that subscription is theirs: the next call of the
session answers 201 with a fresh subscriptionId
instead of touching it.
In sandbox this returns a prefilled funnel link that credits nothing. Never show a sandbox price or a sandbox link to a real traveller.
Try it
Run every endpoint, right here
Paste a sandbox key and send a real signed request to each endpoint. The request is signed in your browser and answered by the live sandbox - no install, no server, nothing billed or credited. Each endpoint carries its schema, a Node snippet and the exact curl, so you can copy what worked.
Prefer your own client? Download the Postman collection:
set your sandbox key once and every request signs itself, and the Quotes
request saves its sessionId so Links works on the next click.
In the playground below, run Quotes first and paste its
sessionId into the Links body.
/api/v1/coach/bilanneeds your keyCoach bilan: coverage gaps and ranked sell arguments
/api/v1/coach/cardsneeds your keyCard catalogue: the banks and cards the Coach knows
Step 5
Go live
Live access is a short review. It is not a sales threshold: we size your daily quota against the traffic you expect, and we record who presents the guarantees to the traveller, which is the question a regulator would ask us.
| Before you apply | Why |
|---|---|
| 10 sandbox prices called | Proves the signature and the trip payload work. |
| 1 sandbox link minted | Proves the whole funnel handoff works, not just the read. |
| Partner account approved | A live key credits real sales, so there has to be someone to credit. |
The checklist turns green on its own in your dashboard, and the application asks for your expected volume, your markets and your distribution status. Approval sets your quotas; you then create the live key yourself, so the signing secret is never seen by anyone at HelloSafe.
Going live gives you a second key, not a promoted one. Your sandbox key stays alive with its own quota, so your tests keep running against fixtures after production traffic starts. The only thing that changes in your code is the pair of credentials it reads from the environment.
Good to know
The five things that go wrong
| Symptom | Cause |
|---|---|
INVALID_SIGNATURE | The body was re-serialised after signing, or the pathname in the signed message does not match the URL you called. |
MISSING_SESSION_ID | /links called without the sessionId from the /quotes response. Sessions are issued, never invented: carry it from quote to click. |
STALE_TIMESTAMP | Server clock drift. Sync it with NTP. |
CANCELLATION_NEEDS_TRIP_PRICE | shouldCoverCancellation is true without a tripPrice to insure. |
QUOTA_EXCEEDED | Daily bucket spent. Read X-RateLimit-Remaining on every response and pace yourself instead of discovering the ceiling. |
Every error code, with its HTTP status, is listed in the error table. The machine readable contract is openapi.json.
Part two
Endpoints
Generated from the spec, version 2.3.0, so what you read here is what the API answers.
Quotes
Price a trip and read the catalogue vocabulary. This is what most integrations use.
/api/v1/travel/meta Reference data and key state
The vocabulary a caller would otherwise hard-code: the 15 trip types, the 26 funnel languages, the 51 US state codes a US resident's stateResidence accepts, the guarantee slugs with their English labels and groups, the guarantee states a response can carry, the request ceilings, and your key's own environment and quota. Does not consume quota.
Responses
| Code | Meaning |
|---|---|
| 200 | Reference data. |
| 401 | |
| 403 |
/api/v1/travel/quotes Price a trip
Prices one trip against the travel catalogue and returns the priced offers, cheapest first, each with its premium, its guarantee ceilings and its policy documents. Read-only: nothing is stored, no subscription is created and no attribution happens here.
Every response also opens a quoting session: `sessionId` is a signed token identifying THIS traveller's flow, and POST /links requires it. To keep one traveller's session across a trip edit, echo the previous `sessionId` in the body — a valid one is returned unchanged, anything else silently starts a fresh session (a broken continuation never fails a pricing call). Never share one sessionId across travellers.
Body
| Field | Type | Req | Description |
|---|---|---|---|
trip | object | yes | |
sessionId | string | no | Optional: the sessionId from THIS traveller's previous /quotes response, to keep their session across a trip edit. A valid token is echoed back; anything else silently starts a fresh session. Never reuse one across travellers. |
language | bg · cs · da · de · el · en · es · et · fi · fr · hr · hu · is · it · lt · lv · mt · nl · no · pl · pt · ro · sk · sl · sv · tr | no | Funnel language. |
Responses
| Code | Meaning |
|---|---|
| 200 | Priced offers. |
| 400 | |
| 401 | |
| 403 | |
| 429 | Refused. QUOTA_EXCEEDED when the daily bucket is spent (Retry-After points at the next UTC day), RATE_LIMITED when more than 60 calls left in the current minute (Retry-After points at the next minute). Neither refusal is counted against your quota. |
| 502 | Upstream pricing failed. |
| 504 | Upstream pricing timed out. |
Links
Turn a chosen offer into a tracked link that credits the sale to you.
/api/v1/travel/links Mint a tracked subscription link
Turns a quoting session into the tracked link handed to the traveller. The first call of a session creates a quoting-stage subscription with your affiliate reference baked in server-side and returns the URL that resumes it; every later call of the SAME session returns the SAME subscription — a repeat offer click replays it (200, `replayed: true`), a changed trip updates it in place (`updated: true`), and a different session can never reach it, so two travellers with identical trips can never share a link. One exception protects the traveller: once they take the subscription past quoting (presubscribed, paid, subscribed), it is frozen, and the next call of the session rolls onto a fresh subscription (201, new subscriptionId) that the session follows from then on.
`sessionId` is REQUIRED and comes from the POST /quotes response — a fabricated value fails its HMAC with BAD_SESSION_ID. Pass the `offerId` the traveller clicked to land them straight on the presubscribe form with that offer selected at the live re-rated premium (the funnel falls back to the offer list when the offer no longer prices); without it the link lands on the offer list.
The affiliate reference lives on the subscription rather than in a query string, so attribution survives a copy-paste through a messaging app, an email client and a browser redirect, and cannot be forged.
Body
| Field | Type | Req | Description |
|---|---|---|---|
sessionId | string | yes | The traveller's quoting session, from the POST /quotes response. Required: one session = one traveller = one subscription. A fabricated value fails with BAD_SESSION_ID. |
trip | object | yes | |
language | string | no | Funnel language shown to the traveller. Default en. |
offerId | integer | no | The `id` of the /quotes offer the traveller clicked. The link then lands on the presubscribe form with that offer selected, re-rated live; omitted, it lands on the offer list. |
linkCode | string | no | One of your own tracked links, to split reporting by channel. A session's attribution is fixed by its first /links call. |
Responses
| Code | Meaning |
|---|---|
| 200 | The session already holds its subscription: replayed (and updated in place when the trip changed). |
| 201 | First mint of this session: the tracked link was created. |
| 400 | |
| 401 | |
| 403 | |
| 429 | Refused. QUOTA_EXCEEDED when the daily bucket is spent (Retry-After points at the next UTC day), RATE_LIMITED when more than 60 calls left in the current minute (Retry-After points at the next minute). Neither refusal is counted against your quota. |
Coach
What a traveller's bank card does not cover, and the arguments that sell the gap.
/api/v1/coach/bilan Coach bilan: coverage gaps and ranked sell arguments
Analyses a traveller's situation against the travel cover carried by their bank card, for the chosen destinations, and returns it as data: the card's GAPS (guarantees in default, adequately covered ones are omitted), a ranked list of arguments each with a strength and a category, the medical recommendation, a social-proof figure and an attributed quote link.
Sell-only by design: it returns the card's shortcomings, never a reason not to buy, except bilan.medical, which gives the trip's recommended medical ceiling (it follows the destination) and whether the card meets it. Copy comes back as message KEYS plus interpolation variables, not finished sentences, so you render it in your own wording. Requires a key carrying the `coach` scope, which every self-serve key now has: the sandbox answers from the real engine, and its `offer.quoteUrl` comes back null because a sandbox key credits nobody.
Body
| Field | Type | Req | Description |
|---|---|---|---|
residence | string | yes | ISO 3166-1 alpha-2. Drives the health socle and the default market. |
destinations | array of string | yes | Where they travel. ISO 3166-1 alpha-2, 1 to 50 codes. |
card | object | no | Exactly one card mode: { catalogueId } | { bin } | { bank, network, tier } | { network, tier } | { none: true }. catalogueId names the exact card: take it from GET /api/v1/coach/cards (a sandbox key identifies cards by BIN or bank instead, see key.cardModes there). A BIN or { bank, network, tier } match by range: at a bank with two cards of one tier, the most basic one wins. A card that cannot be resolved never fails the call: it degrades to a baseline card and adds a code to meta.warnings. |
trip | object | no | Optional booleans, all default false. |
market | fr · us · ca · sg · my · universal | no | Currency, formatting and health socle. Derived from residence when omitted. |
ref | string | no | Overrides the attribution ref baked into the returned quote URL. |
Responses
| Code | Meaning |
|---|---|
| 200 | The bilan. See the guide for the full field by field description. |
| 400 | INVALID_JSON, BAD_RESIDENCE or NO_DESTINATIONS. |
| 401 | UNAUTHORIZED, STALE_TIMESTAMP or INVALID_SIGNATURE. |
| 403 | SCOPE_FORBIDDEN: the key lacks the coach scope. |
| 502 | AUTH_LOOKUP_FAILED or CARD_RESOLVE_FAILED. |
/api/v1/coach/cards Card catalogue: the banks and cards the Coach knows
The bank cards the Coach knows, per country of residence: each bank, then its cards, with the name printed on the card, its picture, the tier the Coach reads from that name, and the catalogueId that names the card exactly in POST /api/v1/coach/bilan. Built for a card picker: the traveller's bank, then their card, then the bilan with { catalogueId }. byBankTier says whether { bank, network, tier } would land on that same card: at a bank with two cards of one tier, only the most basic one is reachable that way. Travellers whose bank is not listed fall back to the generic { network, tier } profiles listed under generic. Signed like every endpoint, with an empty body: the query string is not signed. Does not consume quota.
Query parameters
| Parameter | Req | Description |
|---|---|---|
country | no | Country of residence, ISO 3166-1 alpha-2. Omitted: every country with a bank list (FR, US, CA, SG, MY). Any other valid code returns an empty banks list. |
lang | no | Language of the card names. Default: French for FR, English elsewhere. |
Responses
| Code | Meaning |
|---|---|
| 200 | The catalogue. |
| 400 | |
| 401 | |
| 403 |
Conversion
Report a sale you took yourself, so it can be commissioned.
/api/postback/conversion Report a conversion
Signed server-to-server postback that reports a sale against a tracked link. The short code is re-resolved to a real tracked link and the partner code is checked before any commission is recorded.
Body
| Field | Type | Req | Description |
|---|---|---|---|
ref | string | yes | The tracked ref that carried the sale, partnerCode-shortCode. |
externalOrderId | string | yes | Your own order id. Replaying it updates that sale instead of creating a second one. |
amount | number | yes | Premium paid by the traveller, in the sale currency. |
commission | number | no | Optional. Left out, it is computed server side from the configured rate. |
currency | string | no | ISO 4217. Defaults to EUR. |
status | pending · validated · cancelled | yes | State of the sale. A cancelled sale reverses the commission. |
partnerFeeAmount | number | no | Optional. The uplift slice of the handling fee you actually charged on this sale, in the sale currency. Declarative: the ledger recomputes what it should be from the partner's own grid and pays the lower of the two, so this can only ever reduce what is owed, never inflate it. Leave it out when the offer carried no uplift. |
contractVersion | 3.1 · 3.2 | no | Optional. Which version of this contract you are speaking. Omitting it means 3.1, the shape that predates partnerFeeAmount, and stays valid. A version this endpoint does not know is refused with 400 UNKNOWN_CONTRACT_VERSION rather than parsed leniently: a field silently dropped here is a sale booked at the wrong price, and past the 14-day auto-validation that is no longer repairable. |
Responses
| Code | Meaning |
|---|---|
| 200 | Conversion accepted. |
| 401 |
Environments
Sandbox and live
The URL never changes. The key decides.
Sandbox ak_test_ | Live ak_live_ | |
|---|---|---|
| Prices | Fixed sample data, fictional insurer | The real catalogue, rated by each insurer |
| Insurer calls | None | One per insurer, per request |
Quote created by /links | None | A real quoting-stage subscription |
| Attribution | None (ref: null) | Your affiliate ref, on the subscription |
| Granted | self-serve one click | Short review |
| Default quota | 200 reads, 50 writes per day, 60 per minute | Set from your declared volume |
The response shape is identical in both, deliberately: an integration built against the sandbox runs unchanged in production. Start at the quickstart above.
Authentication
Signed requests
| Header | Value |
|---|---|
x-atlas-key-id | Your key id. |
x-atlas-timestamp | Unix seconds. Rejected beyond a 5 minute window. |
x-atlas-signature | Scheme prefix, then hex HMAC-SHA256 keyed with your signing secret. |
Signature scheme
| Scheme | Signed message | Use |
|---|---|---|
v2= | Covers the timestamp, the method, the path and the raw body. | required |
v1= | Covered the timestamp and the raw body only. | Retired. Every key refuses it with V1_SCHEME_RETIRED. |
v2 `${ts}.${METHOD}.${pathname}.${rawBody}`
# GET has an empty body, so the message simply ends on the dot:
v2 `${ts}.GET./api/v1/travel/meta.`
v2 binds the signature to the endpoint it was minted for. Under v1 a
captured signature replayed on any other route of the same API, since only
the body was covered. That is why v1 is retired: a v1 signature answers
401 V1_SCHEME_RETIRED.
Rotating your secret
Rotating issues a new signing secret and keeps the previous one working for 24 more hours, so you can deploy through a normal release instead of scheduling downtime. The dashboard shows the deadline until the window closes. Revoking is different: it stops the key, and the old secret with it, immediately. That is the path to use if a secret has leaked.
Shared payload
The trip object
Used by /quotes and /links alike. tripInfo
is accepted as an alias for trip.
| Field | Req | Description |
|---|---|---|
intent | yes | One of the 16 trip types below. |
startDate | yes | YYYY-MM-DD. |
endDate | yes | YYYY-MM-DD, on or after startDate. |
countryResidence | yes | ISO 3166-1 alpha-2. Drives eligibility and currency. |
stateResidence | no | US residents only: the state they live in, as its USPS code (NY, TX), one of the 50 states or DC. US travel insurance is regulated state by state, so send it whenever you have it. It changes neither offers nor prices, and it is ignored for any other residence. The territories are countries, not states: a Puerto Rico resident is countryResidence PR. |
arrivalCountries | yes | Array of alpha-2 codes, 1 to 20. |
travellers | yes | Array of objects carrying an age, 1 to 50. Above roughly 10 the individual products give way to the group product. |
tripPrice | cond. | Insured trip cost: the total price of the trip for the whole party, not a per-traveller amount. Required when shouldCoverCancellation is true. |
currency | no | ISO 4217. The currency we price in, and the one tripPrice and studiesAmount are read in. Default EUR. 33 codes convert; anything else leaves prices in the insurer currency instead of failing the call. |
shouldCoverCancellation | no | Default false. |
shouldCoverExtremeSports | no | Default false. |
isAnnual | no | Forced true by the annual and expat intents. |
language (outside trip) | no | Funnel language, default en. 26 supported, see GET /meta. |
The ceilings are deliberate: one call describes one trip, not a customer file.
Trip types
| intent | Covers |
|---|---|
forTourism | Leisure trip, the default |
schengenArea | Schengen visa application, meets the 30k EUR requirement |
annual | Multi-trip annual cover (forces isAnnual) |
studyInternship | Studies or internship abroad |
whv | Working holiday visa |
cruise | Cruise |
digitalNomad | Long remote-work stay |
expat | Expatriation (forces isAnnual) |
groupTravel | Group travel |
rentalStay | Rental accommodation stay |
mountainTrip | Ski and mountain trip |
backToHome | Return to the home country |
humanitarian | Humanitarian mission |
auPair | Au pair stay |
toWork | Work assignment abroad |
cancellation | Cancellation cover only |
Vocabulary
Prices and currency
price.amount is what the traveller pays. Send a
currency in the trip and we convert with the same rate, the
same conversion margin and the same rounding the subscription funnel
displays and the card is charged, so the figure you show and the figure we
debit are the same one.
price.insurerAmount and price.insurerCurrency
are the insurer's own price, before conversion. They are there for
reconciliation. Converting them yourself is the one mistake to avoid: your
rate is not ours, so your customer is quoted less than they are charged
and finds out at the payment screen. When no conversion happened the two
amounts are equal, so displaying amount is always correct.
Currency codes are uppercase on every field.
Your own rate
A partner can set their own commission from their dashboard, between the base rate their account carries and 50%. Everything above the base rate is funded by raising the handling fee on the policy, so the traveller pays the difference, and it applies on every channel: tracked links, the widget, and this API.
price.amount already includes it. It is still what the
traveller pays and still what the card is charged, so nothing about the
rule above changes. When you charge an uplift, the response also carries
meta.partnerFee with feeBps (the whole handling
fee inside those prices), upliftBps (your share of it) and
commissionBps (what a sale pays you), in basis points of the
insurer premium. It is a breakdown, never an amount to add on top. Without
an uplift the object is simply absent.
The rate is frozen onto each sale when the conversion is booked, so moving it never rewrites what an earlier sale paid.
Vocabulary
Guarantees
offer.guarantees maps a slug to a state and, when the
state carries a ceiling, a value.
Ceilings are expressed in guaranteeCurrency, which is not always
the price currency.
| state | Render it as |
|---|---|
value | Covered up to value, in guaranteeCurrency. |
included | Covered, no published ceiling. |
actual_costs | Covered at actual cost, no ceiling. |
per_day | Covered up to value per day. |
return_ticket | Covered in kind: a return ticket home. |
trip_price | Covered up to the insured trip cost (value echoes it). |
studies_amount | Covered up to the insured tuition amount. |
not_available | Not covered by this offer. |
Slugs
| health | Label |
|---|---|
hospitalFeesAbroad | Hospital fees abroad |
directBilling | Direct billing |
hospitalFeesHome | Hospital fees at home |
dailyAllowance | Daily allowance |
disabilityInsurance | Disability or death |
| assistance | Label |
|---|---|
rapatriationAssistance | Repatriation assistance |
earlyReturn | Early return |
researchFees | Search and rescue fees |
cashAdvance | Cash advance |
| trip | Label |
|---|---|
cancelTrip | Trip cancellation |
stayInterruption | Stay interruption |
studiesInterruption | Studies interruption |
transportDelay | Transport delay |
| belongings | Label |
|---|---|
suitcaseInsurance | Suitcase insurance |
electronicsInsurance | Electronics insurance |
| liability | Label |
|---|---|
privateRc | Private liability |
rentalRc | Rental liability |
| activities | Label |
|---|---|
skiInsurance | Ski and mountain insurance |
extremeSports | Extreme sports |
| expat | Label |
|---|---|
expatHospitalFees | Accidents and hospitalisation |
expatMedicalFees | Medical expenses |
expatRepatriation | Repatriation assistance |
expatDentalFees | Dental fees |
expatOpticalFees | Optical fees |
expatCheckupFees | Vaccination and checkups |
expatCivilLiability | Civil liability |
expatMaternity | Maternity |
expatTelemedicine | Teleconsultation |
Labels are English. Localise them on your side, or read the live list from
GET /api/v1/travel/meta, which is the authority as the catalogue
grows. Treat an unknown slug as a forward compatible addition rather than an
error.
No key needed
Embed, without writing a line of code
Not every integration needs an API. The Marketplace embed puts the whole HelloSafe comparator on your page with one script tag: the visitor compares, subscribes and pays without leaving you, and the sale is credited to your account.
<div id="hellosafe-widget"></div>
<script src="https://atlas.hellosafe.com/widget.js"
data-ref="YOUR-TRACKING-CODE"
data-intent="forTourism"
data-residence="FR"
data-lang="en"
data-height="900"></script> Your own snippet, with your tracking code and your defaults already filled in, is generated in the dashboard under Widget. It carries the trip type, the residence country and the language you choose, and each configuration gets its own tracked link so your reporting stays split by page or campaign.
Limits
Quotas
Two ceilings, and they do different jobs. The daily buckets bound what a key does over a day: reads are the pricing and Coach calls, writes are the minted links, capped much lower because one link is one traveller. The burst ceiling bounds one minute, all endpoints together, and it is what actually stops a runaway loop: a daily cap only notices once the damage is done.
X-RateLimit-Limit: 200 X-RateLimit-Remaining: 173 X-RateLimit-Reset: 41230 # seconds until the daily bucket rolls X-RateLimit-Burst-Remaining: 57 # calls left in the current minute
Over the daily cap you get 429 with QUOTA_EXCEEDED,
the bucket name and Retry-After. Over the minute you get
429 with RATE_LIMITED and a Retry-After
of a few seconds. Neither refused call is counted against you, and being
throttled never costs you a day: on a burst refusal the daily headers keep
describing the day, so X-RateLimit-Remaining is what is still
yours once the minute rolls, not zero.
Reading the headers beats discovering the ceiling: they are on every answer, including the Coach.
Edge cases
Errors
The body is a JSON object with an error field carrying the code below, sometimes with a detail field beside it.
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_JSON | Body is not valid JSON. |
| 400 | BAD_LANGUAGE | Language outside the 26 the funnel serves. |
| 400 | BAD_TRIP | trip is missing or not an object. |
| 400 | BAD_INTENT | intent is not one of the 16 trip types. |
| 400 | BAD_DATES | Dates are not YYYY-MM-DD, or endDate is before startDate. |
| 400 | BAD_RESIDENCE | countryResidence is not a 2 letter code. |
| 400 | BAD_STATE_RESIDENCE | countryResidence is US and stateResidence is not one of the 50 states or DC. A territory such as PR is a countryResidence. |
| 400 | NO_DESTINATIONS | arrivalCountries is empty. |
| 400 | TOO_MANY_DESTINATIONS | More than 20 arrival countries. |
| 400 | NO_TRAVELLERS | travellers is empty. |
| 400 | TOO_MANY_TRAVELLERS | More than 50 travellers. |
| 400 | CANCELLATION_NEEDS_TRIP_PRICE | Cancellation cover without a tripPrice. |
| 400 | BAD_LINK_CODE | linkCode is not alphanumeric, 4 to 32 characters. |
| 400 | MISSING_SESSION_ID | /links without a sessionId. Take it from the /quotes response. |
| 400 | BAD_SESSION_ID | The sessionId was not issued by /quotes (or was truncated). |
| 400 | BAD_OFFER_ID | offerId is not a positive integer offer id from /quotes. |
| 401 | UNAUTHORIZED | Missing, unknown or revoked key. |
| 401 | V1_SCHEME_RETIRED | The signature uses the retired v1 scheme. Sign the v2 message. |
| 401 | STALE_TIMESTAMP | Timestamp outside the 5 minute window. |
| 401 | INVALID_SIGNATURE | Signature does not verify against the raw body. |
| 403 | SCOPE_FORBIDDEN | Key lacks the quotes or quotes.link scope. |
| 403 | NO_ATTRIBUTION_CONFIGURED | Live key with no partner to credit. |
| 404 | LINK_NOT_FOUND | linkCode is unknown, archived, or not yours. |
| 429 | QUOTA_EXCEEDED | Daily bucket spent. Retry-After says when it resets. |
| 502 | PRICING_UNAVAILABLE | Upstream pricing refused the call. |
| 502 | QUOTE_CREATE_FAILED | Upstream refused to create the quote. |
| 504 | PRICING_UNAVAILABLE | Upstream pricing timed out. |
| 504 | QUOTE_CREATE_UNAVAILABLE | Upstream quote creation timed out. |
Good to know
Versioning and terms
The path is versioned. Structured fields are stable; new guarantee slugs, trip types and offers can appear, so treat unknown values as forward compatible additions. What an integration accepts: no reselling or redistribution of the prices, no long lived caching of a premium, insurer name and documents stay attached to the offer you display, no real personal data in the sandbox, and server to server only. A secret in a browser bundle is a revoked key.
Ready to build? Back to the quickstart · Get a free sandbox key · openapi.json