- Resources
- 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.
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.
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
- 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
initializesucceeds. - Show your consent screen. Your app asks, in its own words. Hey Tomo does not draw this screen.
- Call
setConsent.truewhen the customer agrees,falsewhen they say no or change their mind. - Identify. After the customer signs in, your app asks your server for a short-lived signed assertion and passes it to
identify. - Track. Send events as things happen in the app.
- Register the push token once the operating system grants permission, and again whenever the token changes.
- Show the inbox and in-app messages with the built-in screens, or your own.
// 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);// AppModel.swift
let tomo = try HeyTomoClient(apiOrigin: apiOrigin, // Settings > Apps & SDK
publishableKey: appKey, // test_pk_... or live_pk_...
appVersion: "1.0",
waitForConsent: true)
_ = try await tomo.initialize()
// Only after YOUR consent screen says yes:
await tomo.setConsent(true)
// After sign-in, with an assertion from YOUR server:
_ = try await tomo.identify(userId: userId, signedToken: assertionFromYourServer)
_ = try await tomo.track("Product Viewed",
properties: ["Product Name": "Masala chai 250 g", "Category": "Tea"])
// PROVIDER: the push service name shown in your dashboard
_ = try await tomo.registerPushToken(provider: PROVIDER, token: pushToken)
let messages = await HeyTomoMessages(client: tomo) // keep a reference while dialogs show
try await messages.showNextPopup(from: viewController)
try await messages.showInbox(from: viewController)// lib/tomo.dart
import 'package:hey_tomo/hey_tomo.dart';
final tomo = await HeyTomoClient.create(
apiOrigin: apiOrigin, // Settings > Apps & SDK
publishableKey: appKey, // test_pk_... or live_pk_...
appVersion: '1.0',
waitForConsent: true,
);
await tomo.initialize();
// Only after YOUR consent screen says yes:
await tomo.setConsent(true);
// After sign-in, with an assertion from YOUR server:
await tomo.identify(userId: userId, signedToken: assertionFromYourServer);
await tomo.track('Product Viewed', {'Product Name': 'Masala chai 250 g', 'Category': 'Tea'});
// PROVIDER: the push service name shown in your dashboard
await tomo.registerPushToken(PROVIDER, pushToken);
await HeyTomoMessages(tomo).showNextPopup(context);
await HeyTomoMessages(tomo).showInbox(context);// tomo.ts
import { NativeModules } from 'react-native';
import { fromNativeModule } from '@heytomo/react-native';
const tomo = fromNativeModule(NativeModules.HeyTomo);
await tomo.init(APP_KEY, { waitForConsent: true }); // test_pk_... or live_pk_...
// Only after YOUR consent screen says yes:
await tomo.setConsent(true);
// After sign-in, with an assertion from YOUR server:
await tomo.identify(userId, assertionFromYourServer);
await tomo.track('Product Viewed', { 'Product Name': 'Masala chai 250 g', Category: 'Tea' });
// PROVIDER: the push service name shown in your dashboard
await tomo.registerPushToken(PROVIDER, pushToken);
const page = await tomo.inbox.list('unread'); // Android today; see the React Native sectionWhich key goes where
| Key | Shape | Lives in | Used for |
|---|---|---|---|
| Publishable app key | test_pk_..., live_pk_... | Your app | Tells 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 key | ht_live_... | Your server only | The server API: events, profiles and widget tokens. Never in an app, a page or a repo. |
| Identity secret | made by you | Your server only | Signing the identity assertion. You paste it once in Settings > Apps & SDK and it is never shown again. |
| Widget token | htw_... | The customer's browser | One 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
Consent and the tracking choice
There are two different yeses, and the kits keep them apart.
- The tracking choice is whether the app may send events and link the phone to the customer. Your app asks for it and tells the kit with
setConsent. - Permission to send messages (marketing, reminders, service) is recorded per channel and purpose, with proof, through your server or Hey Tomo's preference page. A tracking yes is never a marketing yes.
Wait for consent
Create the client with "wait for consent" on. The kit then queues nothing personal until setConsent(true). The workspace can also require waiting from the dashboard. The kit follows whichever of the two is stricter, and if the server cannot be reached it fails closed.
When the customer says no
Call setConsent(false). The kit stops collecting, clears the identity it holds in memory, wipes its offline event queue, drops queued work and starts a new random installation id. A request already on the wire cannot be recalled, and its answer can no longer change anything on the phone.
There is also setTrackingOptOut(true) for a separate "do not track me" switch in your settings screen. It stops analytics in the same way and keeps the saved consent answer as it is.
What the kit keeps on the phone
Only a random installation id (never a hardware id), the tracking choice, the last app version, in-app frequency counts and a small offline event queue (up to 200 events of 8 KiB each, kept in app-private storage and left out of backups). The signed identity assertion stays in memory only, and the push token is never written to the queue.
What Hey Tomo checks on the server
- Consent for that channel and purpose, before every message and again at the moment of sending.
- Customers under 18 never get marketing. Reminders for them can go to a parent or guardian instead.
- A stop request works in any language, including Hinglish like "band karo" or "mat bhejo".
- Quiet hours apply to marketing and reminders. Only service messages may go out at any hour.
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.
- The customer signs in to your app the way they always do.
- Your app asks your server for a Hey Tomo assertion for that customer.
- Your server signs one, valid for 10 minutes at most, with the identity secret only it knows.
- Your app calls
identify(userId, assertion)and waits for it before asking for the inbox or popups.
// 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();// After your own login succeeds
let assertion = try await api.heyTomoAssertion()
do {
_ = try await tomo.identify(userId: customer.id, signedToken: assertion)
} catch let error as SdkError {
// error.code: "invalid_identity", "not_configured", "rate_limited", ...
}
// On sign-out or account change
await 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.
| Field | Value |
|---|---|
alg, typ, kid | HS256, 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. |
iss | Your app id, app_... |
aud | heytomo-sdk |
ws, env | Your workspace id, and live or test to match the app key |
sub | Your own customer id, 1 to 128 characters. It must equal the user id the app sends. |
iat, exp | Issued 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.
// 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", oncloseand on a 401. Oninvalid_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 503not_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
| Event | Who sends it | Main properties (required) |
|---|---|---|
App Installed | App kit, on its own | platform, app_version, utm_source, utm_medium, utm_campaign |
App Launched | App kit, on its own | session_id, platform, app_version, os_version, model, push_permission |
Session Concluded | App kit, on its own | session_id, duration_seconds |
App Version Changed | App kit, on its own | platform, from_version, to_version |
Web Session Started | Website widget, on its own | session_id, landing_url, referrer, device |
UTM Visited | Website widget, on its own | utm_source, utm_medium, utm_campaign, landing_url |
Product Viewed | App, widget or server | Product Name, Category, SKU, Price, Product URL |
Product Added to Cart | App, widget or server | Product Name, Quantity, Category, SKU, Price |
Category Browsed | App, widget or server | Category, Page |
Search Performed | App, widget or server | Search Term, Results Count, Category |
Charged | Your server only | Amount, Payment Mode, Charged ID, Items |
Notification Sent, Delivered, Viewed, Clicked | Hey Tomo | channel, campaign_id, message_id |
Unsubscribed, Resubscribed | Hey Tomo | channel, 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
CTorht_. - 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:
// 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);let id = UUID()
let at = Date()
_ = try await tomo.track("Product Added to Cart",
properties: ["Product Name": "Mustard oil 1 L", "Quantity": 2],
eventId: id, occurredAt: at)
// on failure, call again with the same id, at and propertiesfinal id = HeyTomoClient.randomId();
final at = DateTime.now().toUtc();
await tomo.track('Product Added to Cart', {'Product Name': 'Mustard oil 1 L', 'Quantity': 2},
eventId: id, occurredAt: at);const first = await tomo.track('Product Added to Cart', { 'Product Name': 'Mustard oil 1 L', Quantity: 2 });
// retry with the same envelope
await tomo.track('Product Added to Cart', { 'Product Name': 'Mustard oil 1 L', Quantity: 2 },
{ eventId: first.event_id, occurredAt: savedOccurredAt });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
| Key | What it holds |
|---|---|
Name | Display name, up to 120 characters |
Email | Email address |
Phone | With country code, like +919812345678 |
Language, Timezone | A language code such as en; a time zone such as Asia/Kolkata |
City, Gender, DOB | City; M or F; date of birth as YYYY-MM-DD (birthday messages are marketing, so they need consent) |
Customer Type | customer, supplier, lead, patient, student or other |
Age Band | unknown, under_18 or adult. Under 18 means no marketing; reminders go to the guardian. |
Guardian Identity | The identity of the parent's own profile |
Tags | Up to 50 tags to add (it never removes tags) |
MSG-whatsapp, MSG-sms, MSG-email, MSG-push | false stops that channel. true grants nothing: send a Consent entry instead. |
Consent | A list of consent records with proof (below) |
| Your own keys | Any 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_versionandproofwith at least one ofreference,form_url,document_idorotp_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).
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.
- 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.
- Register the token with
registerPushToken(PROVIDER, token)after permission is granted.PROVIDERis the push service name shown in your dashboard. - Register again whenever the push service gives you a new token. The new one replaces the old.
// 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// 1. Your own primer, then the system prompt
let yes = await HeyTomoPush.prompt(from: self, title: "Get order updates?",
yes: "Yes, notify me", no: "Not now")
if yes { UIApplication.shared.registerForRemoteNotifications() }
// 2. Forward the token your app delegate receives
// PROVIDER: the push service name shown in your dashboard
_ = try await tomo.registerPushToken(provider: PROVIDER, token: tokenString)// Your app shows its own primer and asks for permission, then:
// PROVIDER: the push service name shown in your dashboard
await tomo.registerPushToken(PROVIDER, token);
// ...and again whenever the token is refreshedawait tomo.promptForPush({ title: 'Get order updates?' }); // Android today
// PROVIDER: the push service name shown in your dashboard
await tomo.registerPushToken(PROVIDER, token);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
Notificationevents 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
- Hey Tomo filters. The server returns only messages this customer may see, after checking consent, age and opt-outs.
- 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. - 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.
- 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.
// 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);const page = await tomo.inbox.list('unread'); // 'all' | 'unread' | 'read' | 'archived'
await tomo.inbox.markSeen(id);
const { show } = await tomo.inbox.prepareDisplay(id);
if (show) { renderDetail(message); await tomo.inbox.markRead(id); }
// after a real tap on one of its buttons
await tomo.inbox.recordAction(id, actionId);
await tomo.inApp.pauseDuringCheckout(true);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.
{
"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
| Kit | In-app and inbox |
|---|---|
| Android | All four formats, automatic triggers, frequency caps, built-in inbox, your own inbox (list, seen, read, archive), pauses |
| iOS | showNextPopup and showInbox with native plain-text dialogs |
| Flutter | showNextPopup and showInbox |
| React Native | Android: 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.
/v1/widget/tokenserver API key, scope writecurl -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
}'// server side only
async function heyTomoWidgetToken(customer) {
const res = await fetch(process.env.HEY_TOMO_API + '/v1/widget/token', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.HEY_TOMO_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: customer.id, profile: { Name: customer.name }, ttl_seconds: 86400 }),
});
if (res.status !== 201) throw new Error('widget token: ' + res.status);
return (await res.json()).token;
}{ "token": "htw_...", "expires_at": "2026-10-27T10:00:00.000Z", "contact_id": "<uuid>" }identityis your customer id, 1 to 128 characters. A new one creates the contact.profileis optional: Name, Email, Phone, Language, Timezone and your own keys. Consent keys are refused here.ttl_secondsis 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.
<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>| Attribute | Required | What it does |
|---|---|---|
data-api | Yes | Your api base URL. Must be https:// (localhost works for development). |
data-token | Yes, or HeyTomo.setToken() later | The htw_ token. Without one the widget waits and shows nothing. |
data-workspace | No | Your workspace's public id. Keeps browser storage separate when one site embeds more than one workspace. |
data-token-callback | No | Name of a global function that returns a new token, or a promise of one. |
data-theme | No | light or dark. Default: follows the device. |
data-position | No | right (default) or left. |
data-preferences-url | No | The hosted preference page. Default https://hey-tomo.com/p/. |
data-track-sessions | No | off stops the automatic Web Session Started and UTM Visited events. |
data-mock | No | 1 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:
<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:
- Fires
token_expired. - Calls your callback once (
onTokenExpired, else thedata-token-callbackfunction) and waits up to 15 seconds for a newhtw_token. - 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.
| Call | What 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 |
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-srcand your api host inconnect-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.
Routes
/v1/events/trackevents, up to 100 itemsA 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.
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" }
]
}
} ] }'{ "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.
/v1/profiles/upsertprofile items onlyThe 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.
/v1/widget/tokenone customer's widget tokenMints 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.
HTTP/1.1 429 Too Many Requests
Retry-After: 4
{ "error": "rate_limited", "retry_after_seconds": 4 }| Status | Code | What to do |
|---|---|---|
| 400 | bad_request, bad_json | Read reason, fix the request. Do not retry it unchanged. |
| 401 | unauthorized | The key, token or assertion is missing. |
| 401 | invalid_token, expired_token | Wrong or expired key or token. For the widget, mint a new token. |
| 401 | invalid_identity | The kit clears the identity. Get a fresh assertion from your server. |
| 403 | forbidden | The key lacks the scope, consent is missing, or the message is not this person's. |
| 413, 415 | body_too_large, unsupported_media_type | Send smaller batches; send Content-Type: application/json. |
| 429 | rate_limited | Wait Retry-After seconds, then retry the same request. |
| 503 | not_configured | That feature is not set up for your app yet (for example the identity secret). |
| 503 | unavailable | A 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'sinithas 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
evtIdand each payment a uniqueCharged 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
identifyor 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
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: 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')
}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 Installedcampaign 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
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 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")])
]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 areSdkErrorwith a safecode. - Keep
HeyTomoMessagesalive while its dialogs show. Account changes dismiss built-in messages. - Create one
AutomaticEventsafterinitializeand callforeground()at start and after consent. PassisNewInstallation: trueonly 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
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
dependencies:
hey_tomo:
path: ../hey_tomo # the folder we share with youimport '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
logouton account change andsetConsent(false)on a "no". Built-in dialogs close when the session changes. - The platform is read from the device (
androidorios). Other operating systems are refused.
React Native
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: 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)));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
initonce 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.
tracktakes optional{ eventId, occurredAt }for safe retries.- Send
Chargedfrom your server, not from the app: the server refuses money events from the app.