Hey Tomo
  1. Resources
  2. Developer docs

Developer docs

Bring Hey Tomo into your app, your website and your server. Keep the customer's choices, link the app to the right person, send events, and show push, popups, the inbox and chat.

Android · iOS · Flutter · React Native · Website widget · Server APIKits in early accessAbout 25 min read

Quickstart

Hey Tomo has three pieces a developer touches. The app kits connect your Android, iPhone, Flutter or React Native app. The website widget adds chat with your AI employee and the customer's inbox to your site. The server API lets your own server send orders, payments and customer profiles. Everything else, like journeys, campaigns and reports, the business sets up in the Hey Tomo dashboard.

Early accessThe app kits are in early access. They are not on public package registries yet: we share the kit with you during early access. Your developer gets the api base URL, the app's publishable key and server API keys from the Hey Tomo dashboard, under Settings.

What the app kits do

  • Remember the customer's tracking choice, and send nothing personal before it.
  • Link the app to the signed-in customer, using proof that your server signs.
  • Send events like Product Viewed, plus app installs, launches and sessions on their own.
  • Register the phone for push after the customer allows notifications.
  • Show popups, banners and the inbox, after Hey Tomo checks each one is still allowed.

The kits never send push or messages themselves, never decide marketing consent, and never hold a server key. Those decisions stay on Hey Tomo's servers, which check consent, age, opt-outs and quiet hours before every message and again when it is sent.

The flow, in order

  1. Initialize. Create the client with your api origin, the app's publishable key, your app version and "wait for consent" on. Every other call waits until initialize succeeds.
  2. Show your consent screen. Your app asks, in its own words. Hey Tomo does not draw this screen.
  3. Call setConsent. true when the customer agrees, false when they say no or change their mind.
  4. Identify. After the customer signs in, your app asks your server for a short-lived signed assertion and passes it to identify.
  5. Track. Send events as things happen in the app.
  6. Register the push token once the operating system grants permission, and again whenever the token changes.
  7. Show the inbox and in-app messages with the built-in screens, or your own.
MainActivity.java
// MainActivity.java: the whole flow, in order
HeyTomo tomo = new HeyTomo(getApplicationContext(),
    BuildConfig.HEY_TOMO_API_ORIGIN,   // shown in Settings > Apps & SDK
    BuildConfig.HEY_TOMO_APP_KEY,      // test_pk_... or live_pk_...
    BuildConfig.VERSION_NAME,
    true);                             // wait for consent

tomo.initialize().thenAccept(config -> { /* ready */ });

// Only after YOUR consent screen says yes:
tomo.setConsent(true);

// After the customer signs in, fetch a signed assertion from YOUR server:
tomo.client().identify(userId, assertionFromYourServer)
    .thenAccept(ok -> { /* now the inbox and popups may load */ });

Map<String, Object> props = new HashMap<>();
props.put("Product Name", "Masala chai 250 g");
props.put("Category", "Tea");
tomo.client().track("Product Viewed", props);

// PROVIDER: the push service name shown in your dashboard
tomo.client().registerPushToken(PROVIDER, pushToken);

tomo.showNextPopup(this);

Which key goes where

KeyShapeLives inUsed for
Publishable app keytest_pk_..., live_pk_...Your appTells Hey Tomo which app and workspace is calling. It is safe to ship in the app because it cannot read anyone's data on its own.
Server API keyht_live_...Your server onlyThe server API: events, profiles and widget tokens. Never in an app, a page or a repo.
Identity secretmade by youYour server onlySigning the identity assertion. You paste it once in Settings > Apps & SDK and it is never shown again.
Widget tokenhtw_...The customer's browserOne signed-in customer's chat and inbox on your website, for up to 24 hours.

Build with a test_pk_ key first. A test key never sends messages, never starts journeys and never mixes into live numbers. Switch to the live_pk_ key for the release build.

Pick your kit

Identify the customer

Until identify succeeds, the app is just an anonymous installation. Its events are stored against that installation only, and it cannot read any inbox or popup. Identifying links the installation to your customer so journeys, the inbox and push reach the right person.

Why it must be signed

A user id on its own proves nothing. Anyone could type "cust-1001" and read that person's inbox. So identify needs a short-lived identity assertion that your own server signs after the customer logs in. The kit neither signs nor checks it; it carries it to Hey Tomo, which checks the signature, the expiry, the app, the workspace and that it names the same user id.

  1. The customer signs in to your app the way they always do.
  2. Your app asks your server for a Hey Tomo assertion for that customer.
  3. Your server signs one, valid for 10 minutes at most, with the identity secret only it knows.
  4. Your app calls identify(userId, assertion) and waits for it before asking for the inbox or popups.
LoginActivity.java
// After your own login succeeds
api.fetchHeyTomoAssertion(session).thenCompose(assertion ->
    tomo.client().identify(customer.id, assertion)
).exceptionally(err -> {
  // invalid_identity: fetch a fresh assertion and try again
  // not_configured: the identity secret is not set up yet
  return null;
});

// On sign-out or when another person signs in on this phone
tomo.logout();

The assertion

It is a compact signed JWT, at most 2,048 bytes. The table shows the current early-access format; Settings > Apps & SDK shows your app id and the key id to use.

FieldValue
alg, typ, kidHS256, heytomo-id+jwt, and the identity secret's id (ids_...). Nothing else is accepted, so an ordinary login token from your server cannot be reused here.
issYour app id, app_...
audheytomo-sdk
ws, envYour workspace id, and live or test to match the app key
subYour own customer id, 1 to 128 characters. It must equal the user id the app sends.
iat, expIssued and expiry times in seconds. The lifetime is 10 minutes at most.

Email, phone, consent or profile values inside the token are ignored. Profiles and consent come only from your server, through the profiles route.

sign-identity.js
// server side only: sign-identity.js (Node 18 or later, no libraries)
import { createHmac } from 'node:crypto';

const b64url = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url');

export function heyTomoAssertion(customerId) {
  const now = Math.floor(Date.now() / 1000);
  const header = { alg: 'HS256', typ: 'heytomo-id+jwt', kid: process.env.HEY_TOMO_IDENTITY_KID };
  const claims = {
    iss: process.env.HEY_TOMO_APP_ID,        // app_... from Settings > Apps & SDK
    aud: 'heytomo-sdk',
    ws: process.env.HEY_TOMO_WORKSPACE_ID,
    env: 'live',                             // 'test' with a test_pk_ app key
    sub: customerId,                         // must equal the user id the app sends
    iat: now,
    exp: now + 300,                          // 10 minutes at most
  };
  const input = b64url(header) + '.' + b64url(claims);
  const sig = createHmac('sha256', process.env.HEY_TOMO_IDENTITY_SECRET).update(input).digest('base64url');
  return input + '.' + sig;
}

Rules the kit follows

  • The assertion stays in memory. It is cleared on logout, on a consent "no", on close and on a 401. On invalid_identity, get a fresh one from your server.
  • Call logout() when the customer signs out or someone else signs in on the same phone. It clears the identity and old queued events, starts a new installation id and keeps the saved consent answer.
  • Until your app's identity secret is added in Settings > Apps & SDK, identify, the inbox and in-app calls answer 503 not_configured. There is no fallback that trusts an unsigned user id.
  • Keep user ids to 128 characters, and never log assertions, tokens or payloads.

Events

An event is something a customer did: viewed a product, searched, booked a slot. Journeys start from events, and Analyze counts them. Use a standard name when one fits, so ready journeys and reports work without changes.

track(name, properties) sends one event. Property keys for standard events use the names below, like Product Name. Values can be text, numbers, true or false, or lists.

Standard events

EventWho sends itMain properties (required)
App InstalledApp kit, on its ownplatform, app_version, utm_source, utm_medium, utm_campaign
App LaunchedApp kit, on its ownsession_id, platform, app_version, os_version, model, push_permission
Session ConcludedApp kit, on its ownsession_id, duration_seconds
App Version ChangedApp kit, on its ownplatform, from_version, to_version
Web Session StartedWebsite widget, on its ownsession_id, landing_url, referrer, device
UTM VisitedWebsite widget, on its ownutm_source, utm_medium, utm_campaign, landing_url
Product ViewedApp, widget or serverProduct Name, Category, SKU, Price, Product URL
Product Added to CartApp, widget or serverProduct Name, Quantity, Category, SKU, Price
Category BrowsedApp, widget or serverCategory, Page
Search PerformedApp, widget or serverSearch Term, Results Count, Category
ChargedYour server onlyAmount, Payment Mode, Charged ID, Items
Notification Sent, Delivered, Viewed, ClickedHey Tomochannel, campaign_id, message_id
Unsubscribed, ResubscribedHey Tomochannel, purpose, source

The app kit sends App Installed only on a real first install, so an existing app that adds the kit in an update gets launches but no install. App Launched fires on a cold start or after 20 minutes away. Events written by Hey Tomo are refused if a client sends them (reserved_event_name).

Your own events

Name custom events as a noun and a past-tense verb, two to six words, each starting with a capital or a digit: Appointment Booked for a clinic, Demo Class Joined for a coaching centre, Table Reserved for a restaurant, Coupon Applied for a D2C brand. A workspace can have up to 512 custom event names.

Limits

  • Event names up to 64 characters; up to 100 properties per event; text values up to 512 characters.
  • Property keys cannot start with CT or ht_.
  • An event's time can be at most 30 days in the past and 5 minutes in the future.

Money is only trusted from your server

Anything that runs on a customer's phone or browser can be changed by that customer. So app and widget events are stored as reported, but their amounts never count as revenue, orders or payments. Charged is refused from the app and the browser. Send it from your server once the payment is really collected (see Server API).

Offline, retries and duplicates

With the offline queue, track answers queued: true as soon as the event is safely saved on the phone. That means "saved", not "received". The kit tries to send within about five seconds while the app is running, and again when it goes to the background. Each event keeps its own id and time, so a retry never counts twice.

If you retry a call yourself, pass the same event id, time and properties again:

Cart.java
// One attempt: the kit makes the id and time for you
tomo.client().track("Search Performed", Collections.singletonMap("Search Term", "basmati"));

// Your own retry: reuse the SAME id, time and properties
String id = UUID.randomUUID().toString();
String at = Instant.now().toString();
tomo.client().track(id, "Product Added to Cart", at, props);

Profiles

A profile is what Hey Tomo knows about one customer: name, phone, language, city, tags and your own fields. The app kits cannot set email, phone, profile fields or consent. Those come from your server through POST /v1/profiles/upsert, so a customer's phone cannot change someone else's details.

Every profile item needs an identity: your own customer id, up to 128 characters. The same identity always maps to the same Hey Tomo contact. When matching, Hey Tomo looks at Identity first, then Phone, then Email.

Standard profile keys

KeyWhat it holds
NameDisplay name, up to 120 characters
EmailEmail address
PhoneWith country code, like +919812345678
Language, TimezoneA language code such as en; a time zone such as Asia/Kolkata
City, Gender, DOBCity; M or F; date of birth as YYYY-MM-DD (birthday messages are marketing, so they need consent)
Customer Typecustomer, supplier, lead, patient, student or other
Age Bandunknown, under_18 or adult. Under 18 means no marketing; reminders go to the guardian.
Guardian IdentityThe identity of the parent's own profile
TagsUp to 50 tags to add (it never removes tags)
MSG-whatsapp, MSG-sms, MSG-email, MSG-pushfalse stops that channel. true grants nothing: send a Consent entry instead.
ConsentA list of consent records with proof (below)
Your own keysAny other key becomes a custom field: text, number, true/false or a list of text. Up to 256 per customer.

Recording consent

Each Consent entry names a channel (whatsapp, sms, email, push, in_app), a purpose (marketing, service, transactional, reminders, feedback), a status (granted, withdrawn, denied) and a source such as web_form or in_person.

  • A grant needs a notice_version and proof with at least one of reference, form_url, document_id or otp_verified: true.
  • A marketing grant also needs proof.birth_date. Only the age band is kept, never the date. If the person is under 18, the grant is not recorded.
  • An SMS marketing grant needs otp_verified: true.
  • Withdrawn and denied need no proof and always apply.
  • A grant never overrides an earlier stop unless the person asked again themselves (a form, in person or on paper, with proof).
profiles-upsert.sh
curl -X POST "https://<your-api-base>/v1/profiles/upsert" \
  -H "Authorization: Bearer $HEY_TOMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "d": [ {
        "type": "profile",
        "identity": "CUST-1042",
        "profileData": {
          "Name": "Asha Verma",
          "Email": "[email protected]",
          "Phone": "+919812345678",
          "City": "Jaipur",
          "Language": "en",
          "Timezone": "Asia/Kolkata",
          "Favourite Brand": "India Gate",
          "Consent": [ {
            "channel": "email",
            "purpose": "marketing",
            "status": "granted",
            "source": "web_form",
            "notice_version": "2026-10-v1",
            "captured_at": "2026-10-09T08:00:00Z",
            "proof": { "form_url": "https://yourshop.example/signup",
                       "reference": "form-77812",
                       "birth_date": "1990-05-01" }
          } ]
        }
      } ] }'

Widget tokens accept a small profile too, but never consent keys (consent_not_allowed_here): consent comes from the customer.

Push notifications

Push needs three parts. The business connects its push service in the dashboard, under Settings > Channels. Your app asks the customer for permission and gets a token from the push service. The kit registers that token with Hey Tomo. The kits never send push and never hold push credentials.

  1. Ask at a good moment. Show a short primer first ("Get order updates?"), then the system prompt. The Android and iOS kits include a native primer.
  2. Register the token with registerPushToken(PROVIDER, token) after permission is granted. PROVIDER is the push service name shown in your dashboard.
  3. Register again whenever the push service gives you a new token. The new one replaces the old.
MainActivity.java
// 1. Your own primer, then the Android 13+ permission prompt
HeyTomo.promptForPush(this, "Get order updates?", "Yes, notify me", "Not now", REQ_PUSH);

// 2. In your permission callback, when it was granted
@Override public void onRequestPermissionsResult(int code, String[] p, int[] r) {
  super.onRequestPermissionsResult(code, p, r);
  if (code == REQ_PUSH && r.length > 0 && r[0] == PackageManager.PERMISSION_GRANTED) {
    // get the token from your push service, then:
    // PROVIDER: the push service name shown in your dashboard
    tomo.client().registerPushToken(PROVIDER, token);
  }
}

// 3. Also call registerPushToken from your push service's token-refresh callback

On Android 13 and later, HeyTomo.promptForPush(activity, title, yes, no, requestCode) shows the primer and then the system permission prompt. Before Android 13, use your own notification-enabled check. On iOS, HeyTomoPush.prompt returns whether the customer agreed; your app then registers for remote notifications and forwards the token from its app delegate. The iOS kit also has a helper that takes the raw device token from the delegate and converts it for you.

  • Registering a token needs the tracking consent. The token is kept private and is never written to the offline queue.
  • Once identified, the phone's token is linked to that customer. A token is never moved to another person on the strength of an installation id alone.
  • Every push still goes through the server checks: consent, under-18 rules, opt-outs and quiet hours. In test mode nothing is sent.
  • The kits report delivered, opened and clicked receipts. Hey Tomo checks the message belongs to that phone and writes the Notification events itself.

In-app messages and the inbox

In-app messages show inside your app while the customer is using it: a popup, a top banner, a bottom banner or a full screen. The inbox is a list of messages the customer can come back to, like an order update or a class timetable. Both need a verified identity.

How a message gets shown

  1. Hey Tomo filters. The server returns only messages this customer may see, after checking consent, age and opt-outs.
  2. A trigger fires. Each in-app message names a trigger event and a delay. On Android, events your app tracks, including the automatic App Launched, reach the screen in front on their own.
  3. The kit asks again. Right before showing, the kit asks the server whether it may show this message now. The server checks again and counts it against the frequency cap. An answer older than five seconds is not used.
  4. The kit checks locally that consent and identity have not changed, then shows it.

Content is plain text, with up to four buttons. A link opens only after a real tap, only over HTTPS, and through the phone's own browser or app.

Pausing

Nobody wants a popup in the middle of paying. pauseDuringCheckout(true) holds popups until you call it with false. suspendInApp() and resumeInApp() are a separate pause for any other screen. The two are independent: resuming does not end a checkout pause.

MainActivity.java
// Show the next popup that is due for "App Launched"
tomo.showNextPopup(activity);

// The built-in inbox screen
tomo.showInbox(activity);

// Or build your own inbox
tomo.client().inbox("unread", null).thenAccept(page -> {
  // page: { messages: [...], next_cursor: null | "..." }
});
tomo.client().markRead(messageId);
tomo.client().archive(messageId);

// Keep popups away from the payment screen
tomo.pauseDuringCheckout(true);
// ... payment done
tomo.pauseDuringCheckout(false);

The inbox

Use the built-in screen (showInbox) or build your own from the inbox call. Pages are newest first, up to 100 at a time, with an opaque cursor for the next page. Filter by all (everything not archived), unread, read or archived. Seen, read and archived states sync across the customer's devices. Read implies seen, and there is no unarchive yet.

inbox response
{
  "messages": [ {
    "id": "a6d1...",
    "title": "Your order is on the way",
    "body": "Order 1042 left our Jaipur store at 4 pm.",
    "created_at": "2026-10-20T10:30:00Z",
    "seen_at": null, "read_at": null, "archived_at": null,
    "actions": [ { "id": "track", "label": "Track order", "url": "https://yourshop.example/orders/1042" } ]
  } ],
  "next_cursor": null
}

What each kit covers today

KitIn-app and inbox
AndroidAll four formats, automatic triggers, frequency caps, built-in inbox, your own inbox (list, seen, read, archive), pauses
iOSshowNextPopup and showInbox with native plain-text dialogs
FluttershowNextPopup and showInbox
React NativeAndroid: inbox list, seen, read, archive, detail check, button taps and pauses. iOS: not yet; calls return an "unsupported" error, never a silent success.

Website widget: chat and inbox

The widget adds a small launcher to your website. It opens a panel with two tabs: Messages, the customer's inbox, and Chat, a conversation with your AI employee that a person from your team can take over. It is one file of plain JavaScript with no dependencies and no cookies. It draws inside a Shadow DOM, so your page's CSS and the widget's CSS never mix.

The widget only works for a signed-in customer, with a short-lived token your server mints.

1. Mint a token on your server

When a signed-in customer loads a page, your server asks for a widget token. Never call this from the browser: your API key would leak, and the route refuses browser calls.

POST/v1/widget/tokenserver API key, scope write
mint-token.sh
curl -X POST "https://<your-api-base>/v1/widget/token" \
  -H "Authorization: Bearer $HEY_TOMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "identity": "cust-1001",
        "profile": { "Name": "Ravi Kumar", "Language": "en" },
        "ttl_seconds": 86400
      }'
201 Created
{ "token": "htw_...", "expires_at": "2026-10-27T10:00:00.000Z", "contact_id": "<uuid>" }
  • identity is your customer id, 1 to 128 characters. A new one creates the contact.
  • profile is optional: Name, Email, Phone, Language, Timezone and your own keys. Consent keys are refused here.
  • ttl_seconds is 300 to 86400 (24 hours, the default).
  • Limit: 60 tokens a minute per API key. Mint once per session or page load, not per request.

2. Add the script tag

Put the token into the page you render, or fetch it from your own endpoint, and add the script. Use the api base URL shown in your dashboard.

your page
<script src="https://hey-tomo.com/widget/hey-tomo-widget.js" async
        data-api="https://<your-api-base>"
        data-workspace="<your workspace public id>"
        data-token="htw_..."
        data-token-callback="renewHeyTomoToken"></script>
AttributeRequiredWhat it does
data-apiYesYour api base URL. Must be https:// (localhost works for development).
data-tokenYes, or HeyTomo.setToken() laterThe htw_ token. Without one the widget waits and shows nothing.
data-workspaceNoYour workspace's public id. Keeps browser storage separate when one site embeds more than one workspace.
data-token-callbackNoName of a global function that returns a new token, or a promise of one.
data-themeNolight or dark. Default: follows the device.
data-positionNoright (default) or left.
data-preferences-urlNoThe hosted preference page. Default https://hey-tomo.com/p/.
data-track-sessionsNooff stops the automatic Web Session Started and UTM Visited events.
data-mockNo1 shows sample data and makes no network requests. For design reviews only, never on a live site.

Instead of attributes, you can set window.HeyTomoConfig before the script loads, with the same names in camelCase plus an onTokenExpired function:

your page
<script>
  window.HeyTomoConfig = {
    api: 'https://<your-api-base>',
    token: 'htw_...',
    // ask YOUR server for a fresh token; return null when nobody is signed in
    onTokenExpired: () => fetch('/my/hey-tomo-token', { credentials: 'include' }).then((r) => r.text()),
  };
</script>
<script src="https://hey-tomo.com/widget/hey-tomo-widget.js" async></script>

3. Token renewal

Tokens last up to 24 hours. When the api answers 401 (expired_token or invalid_token), the widget:

  1. Fires token_expired.
  2. Calls your callback once (onTokenExpired, else the data-token-callback function) and waits up to 15 seconds for a new htw_ token.
  3. Carries on with the new token, repeating the call that failed. Without one, it stops and tells the customer the session has ended. HeyTomo.setToken(newToken) resumes it at any time.

Your callback should ask your server for a fresh token for the signed-in customer, and return null when nobody is signed in.

4. The JavaScript API

window.HeyTomo exists once the script has run, and heytomo:ready fires on window.

CallWhat it does
HeyTomo.open(tab?), HeyTomo.close()Opens the panel on 'msgs' or 'chat'; closes it
HeyTomo.refresh()Fetches the inbox and chat now
HeyTomo.setToken(token)Uses a new htw_ token
HeyTomo.on(event, fn), HeyTomo.off(event, fn)Events: unread, message, open, close, token_expired, ready
HeyTomo.track(name, data)Sends one event for this customer from the browser
HeyTomo.unread, HeyTomo.destroy()Unread count; stops and removes the widget
shop.js
HeyTomo.on('unread', (n) => { document.title = n ? `(${n}) Sharma Kirana` : 'Sharma Kirana'; });
document.querySelector('#help').addEventListener('click', () => HeyTomo.open('chat'));

// from the browser: only the events the widget may send
HeyTomo.track('Product Viewed', { 'Product Name': 'Basmati rice 5 kg', Category: 'Rice', SKU: 'BAS-5' });

From the browser, the widget may send Product Viewed, Product Added to Cart, Category Browsed, Search Performed and your own custom events. The customer comes from the token, so you never send an identity. Charged and Hey Tomo's own events are refused (event_not_allowed_here).

Chat, privacy and your site's CSP

  • "Talk to a person" hands the chat to your team. Messages from the AI employee are labelled as the AI assistant.
  • Chat limits: 10 messages a minute and 200 a day per customer, 2,000 characters each.
  • The widget only talks to your api base URL, with no cookies and no referrer, and loads nothing else. Message text is always shown as text, never run.
  • Content Security Policy: allow the script's origin in script-src and your api host in connect-src. style-src 'unsafe-inline' is not needed.
  • It works from 320 px wide, every control is at least 44 by 44 px, and it follows light and dark mode.

Server API

Your server talks to Hey Tomo over HTTPS with JSON. The base is the api base URL shown in your dashboard; the examples write it as https://<your-api-base>.

Authentication

Send Authorization: Bearer <server API key> and Content-Type: application/json. Owners and admins create keys in Settings. Give each key only the scope it needs: these routes need write. The key is shown once, so put it straight into your server's secret store. Keys can expire and can be revoked.

Server onlyNever put a server API key in an app, a web page, a public repo or a chat. These routes refuse browser calls (no CORS) on purpose. If a key leaks, revoke it in Settings and make a new one.

Routes

POST/v1/events/trackevents, up to 100 items

A batch is { "d": [ ... ] }. Each event item has type: "event", identity, evtName and evtData, plus an optional evtId (makes a retry safe) and ts (Unix seconds, milliseconds or ISO 8601). This is where Charged goes: Charged ID must be unique per order, and sending the same order again counts as a duplicate, not a second payment. Send the amount you actually collected.

charged.sh
curl -X POST "https://<your-api-base>/v1/events/track" \
  -H "Authorization: Bearer $HEY_TOMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "d": [ {
        "type": "event",
        "identity": "cust-1001",
        "evtName": "Charged",
        "evtId": "ORD-10025",
        "evtData": {
          "Amount": 2500,
          "Payment Mode": "UPI",
          "Charged ID": "ORD-10025",
          "Items": [
            { "Category": "Rice", "Name": "Basmati rice 5 kg", "Quantity": 2, "Price": 950, "SKU": "BAS-5" },
            { "Category": "Oil", "Name": "Mustard oil 1 L", "Quantity": 3, "Price": 200, "SKU": "MUS-1" }
          ]
        }
      } ] }'
200 OK
{ "accepted": 1, "duplicates": 0, "enrolled": 1, "rejected": [], "warnings": [] }

enrolled counts the journeys the batch started. A refused item names its index and a reason, such as missing_required_property. Fix it and send only that item again. When every item is refused, the answer is 400 with reason: "items_rejected" and the same lists.

POST/v1/profiles/upsertprofile items only

The same batch shape, with type: "profile", identity and profileData. An event item here is refused (bad_type). See Profiles for the keys and consent rules. Warnings you may see include consent_flag_ignored, minor_consent_ignored and consent_blocked_by_stop.

POST/v1/widget/tokenone customer's widget token

Mints an htw_ token for the website widget. See Website widget.

Limits

  • Events and profiles share one bucket per key: 1,000 items in a burst, then 50 a second.
  • Widget tokens: 60 a minute per key.
  • A track or profile body can be up to 1 MiB.

Errors, retries and limits

Every error is JSON with a machine-readable code, like { "error": "forbidden" }. Some 400s add a reason or field. A 429 adds retry_after_seconds and a Retry-After header. Error bodies never echo what you sent.

429 answer
HTTP/1.1 429 Too Many Requests
Retry-After: 4

{ "error": "rate_limited", "retry_after_seconds": 4 }
StatusCodeWhat to do
400bad_request, bad_jsonRead reason, fix the request. Do not retry it unchanged.
401unauthorizedThe key, token or assertion is missing.
401invalid_token, expired_tokenWrong or expired key or token. For the widget, mint a new token.
401invalid_identityThe kit clears the identity. Get a fresh assertion from your server.
403forbiddenThe key lacks the scope, consent is missing, or the message is not this person's.
413, 415body_too_large, unsupported_media_typeSend smaller batches; send Content-Type: application/json.
429rate_limitedWait Retry-After seconds, then retry the same request.
503not_configuredThat feature is not set up for your app yet (for example the identity secret).
503unavailableA short outage. Retry with growing waits.

Codes from the kits

  • feature_disabled: the inbox or in-app messages are off for this app in the dashboard.
  • cancelled: the session changed (logout, a consent "no", a new identity) while the call was waiting.
  • not_configured, already_configured, initialization_in_progress: React Native's init has not run, has already run, or is still running.
  • queue_full: the React Native bridge already has 100 calls waiting.

Retries and idempotency

  • Server events. Give each event an evtId and each payment a unique Charged ID. Then retrying the same body after a timeout, a 429 or a 503 can never count twice.
  • App events. The offline queue retries on its own with the original ids. For your own retries, reuse the event id and time (see Events). Other kit calls, like identify or push registration, are not retried automatically, so your code decides.
  • The widget backs off on errors, doubling each time and never sooner than the server's retry_after_seconds. It pauses polling while the tab is hidden.

Kit limits

  • Requests up to 64 KiB, responses up to 1 MiB. HTTPS only, and redirects are refused so keys never cross to another host.
  • The Android kit queues at most 100 operations. The Flutter kit runs at most 16 requests at once.
  • Kit errors are sanitized and the kits do no request logging. Do not log assertions, tokens or payloads in your own code either.

Android

  • Java
  • package com.heytomo.sdk
  • minSdk 26
  • compileSdk 35
  • no runtime dependencies

The Android kit is a Gradle library module. We share the kit folder with you during early access; add it to your project as a module. Building it needs Gradle 8.9 or later, JDK 17 or later and Android SDK 35.

settings.gradle
// settings.gradle: point at the kit folder we share with you
include ':heytomo'
project(':heytomo').projectDir = new File(rootDir, 'libs/heytomo-android')

// app/build.gradle
dependencies {
  implementation project(':heytomo')
}
ShopApp.java
public class ShopApp extends Application {
  public static HeyTomo tomo;

  @Override public void onCreate() {
    super.onCreate();
    tomo = new HeyTomo(this, BuildConfig.HEY_TOMO_API_ORIGIN, BuildConfig.HEY_TOMO_APP_KEY,
        BuildConfig.VERSION_NAME, true);
    tomo.initialize().exceptionally(err -> null);   // all other calls wait for this
  }
}

// ConsentActivity.java: your own screen
void onConsentAnswer(boolean yes) {
  ShopApp.tomo.setConsent(yes);
}
  • Calls return a CompletableFuture. Handle failures. Callbacks run on the kit's own executor, while the built-in dialogs move to the Activity's UI thread for you.
  • tomo.client() holds identify, track, push and inbox calls. client().anonymousId() is the random installation id.
  • The kit installs lifecycle callbacks for automatic events. For App Installed campaign details, it reads the Play Store install referrer after consent, with a two-second limit, and keeps only the five UTM fields.
  • Popups and banners are native. Links need a tap and HTTPS.

iOS

  • Swift 5.7+
  • iOS 15+
  • Swift package
  • no third-party packages

The iOS kit is a local Swift package named HeyTomo. We share it with you during early access; add it with Xcode's "Add Local..." package option or a path dependency.

Package.swift
// Package.swift of your app, or File > Add Package Dependencies > Add Local... in Xcode
dependencies: [
  .package(path: "../HeyTomo")      // the folder we share with you
],
targets: [
  .target(name: "Shop", dependencies: [.product(name: "HeyTomo", package: "HeyTomo")])
]
Tomo.swift
import HeyTomo

@MainActor final class Tomo {
  static let shared = try? Tomo()
  let client: HeyTomoClient
  lazy var messages = HeyTomoMessages(client: client)   // keep it alive
  var auto: AutomaticEvents?

  private init() throws {
    client = try HeyTomoClient(apiOrigin: Config.apiOrigin, publishableKey: Config.appKey,
                                appVersion: Config.version, waitForConsent: true)
  }

  func start() async {
    _ = try? await client.initialize()
    auto = AutomaticEvents(client: client, appVersion: Config.version)
    await auto?.foreground()   // and again right after consent is granted
  }
}
  • All client calls are async. Errors are SdkError with a safe code.
  • Keep HeyTomoMessages alive while its dialogs show. Account changes dismiss built-in messages.
  • Create one AutomaticEvents after initialize and call foreground() at start and after consent. Pass isNewInstallation: true only from your own first-install check.
  • The installation id and tracking choice are kept in UserDefaults, scoped by the app key. Networking refuses redirects.

Flutter

  • Dart 3.3+
  • Flutter 3.19+
  • Android API 26+
  • iOS 15+
  • no third-party packages

The Flutter kit is a plugin package named hey_tomo. We share it with you during early access; add it as a path dependency. It is not on the public package registry.

pubspec.yaml
# pubspec.yaml
dependencies:
  hey_tomo:
    path: ../hey_tomo      # the folder we share with you
lib/tomo.dart
import 'package:hey_tomo/hey_tomo.dart';

late final HeyTomoClient tomo;
late final AutomaticEvents autoEvents;

Future<void> startTomo() async {
  tomo = await HeyTomoClient.create(
    apiOrigin: apiOrigin, publishableKey: appKey, appVersion: '1.0', waitForConsent: true);
  await tomo.initialize();
  autoEvents = AutomaticEvents(tomo);   // keep it for the app's lifetime; close() on dispose
}

// On sign-out
await tomo.logout();
// If the customer says no on your consent screen
await tomo.setConsent(false);
  • The app owns its notification primer, permission flow and push service setup. Pass refreshed tokens to registerPushToken.
  • Call logout on account change and setConsent(false) on a "no". Built-in dialogs close when the session changes.
  • The platform is read from the device (android or ios). Other operating systems are refused.

React Native

  • TypeScript
  • React Native 0.74+
  • draft
DraftThe React Native kit is a draft and not ready for release yet. Automatic linking is not finished, and on iOS the inbox, in-app and push primer calls return an "unsupported" error.

It is a plain TypeScript layer over the native Android and iOS kits, which keep the installation id, the offline queue and the network calls. Never give it a server API key. On Android, register its package by hand:

MainApplication.java
// MainApplication.java: register ONE package, with a factory that makes a new facade per bridge
packages.add(new com.heytomo.sdk.rn.HeyTomoPackage((key, wait) ->
    new com.heytomo.sdk.HeyTomo(application, sdkApiOrigin, key, appVersion, wait)));
tomo.ts
import { NativeModules } from 'react-native';
import { fromNativeModule } from '@heytomo/react-native';

export const tomo = fromNativeModule(NativeModules.HeyTomo);

export async function startTomo() {
  try {
    await tomo.init(APP_KEY, { waitForConsent: true });   // once per JavaScript instance
  } catch (e) {
    // e.code: 'already_configured', 'initialization_in_progress', 'invalid_configuration', ...
  }
}

export const onConsent = (yes: boolean) => tomo.setConsent(yes);
export const onSignOut = () => tomo.logout();
  • Call init once per JavaScript instance. To change configuration, await close() and create a fresh client.
  • Privacy values must be real booleans; anything else fails without changing the saved choice.
  • track takes optional { eventId, occurredAt } for safe retries.
  • Send Charged from your server, not from the app: the server refuses money events from the app.
Need a hand?Stuck on a kit or an error code? Tell your Hey Tomo early-access contact which call failed and the error code you saw. Never send a key, token or assertion. For the business side, start with Build your first journey.

Start free

Put what you read to work today

Sign up with your work email. We send a six-digit code, and your first journey is ready in minutes.

No card needed. Stop any time.