If your store is your own code — a Next.js app, a Rails app, anything that is not WooCommerce or Shopify — this is how you connect it. Your server sends us what happens in the store: page views, carts, checkouts, orders, sign-ups. We store it against the right person, and your flows run from it. Abandoned cart, after-they-order and the rest work the same as they do for a WooCommerce shop.
You need two things:
Every example on this page is a whole request. Each one is shown twice: once with curl, once with Node's built-in fetch.
The key starts with ae_live_. It is shown once. Copy it before you leave the page. We only keep a fingerprint of it, so if you lose it, make a new one.
Keep it on your server, in an environment variable. Never put it in a web page or in anything the browser downloads. In Next.js that means a server-only variable (not one starting with NEXT_PUBLIC_), used from a route handler or a server action.
The examples below read the key from AE_KEY:
export AE_KEY='ae_live_PASTE-YOUR-KEY-HERE'
A key made on that screen only works on a signed request. The key alone is not enough. Every request carries three headers:
| Header | Value |
|---|---|
Authorization |
Bearer followed by your key |
X-AE-Timestamp |
The time now, in Unix seconds. Milliseconds also work. |
X-AE-Signature |
sha256= followed by the hex HMAC-SHA256 of timestamp + "." + body, using your key as the secret |
Three rules:
This small Node helper (Node 18 or newer) does all of it. The rest of this page uses it.
import crypto from 'node:crypto'
const KEY = process.env.AE_KEY
const BASE = 'https://app.aminoengine.com'
export async function ae(method, path, body) {
const raw = body === undefined ? '' : JSON.stringify(body)
const ts = String(Math.floor(Date.now() / 1000))
const sig = 'sha256=' + crypto
.createHmac('sha256', KEY)
.update(`${ts}.${raw}`)
.digest('hex')
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
'X-AE-Timestamp': ts,
'X-AE-Signature': sig,
...(raw ? { 'Content-Type': 'application/json' } : {}),
},
body: raw || undefined,
})
return { status: res.status, json: await res.json() }
}
GET /api/v1/ping changes nothing. It is the right first call.
TS=$(date +%s)
SIG=$(printf '%s' "$TS." \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/ping \
-H "Authorization: Bearer $AE_KEY" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG"
const { status, json } = await ae('GET', '/api/v1/ping')
A good answer is 200 with "ok": true, your brand's name, and "signingRequired": true.
A missing signature, a wrong signature and an old timestamp all get the same answer. It does not mention the signature, so learn to recognise it:
401—"error": "unauthorized""This API key is missing, malformed, revoked, or does not exist. Reconnect the plugin with a fresh key."
If you see it and the key is right, check the signature and your server's clock first.
You may send one more header, X-AE-Site-URL, with your store's address, such as https://yourstore.com.
403, or 409 on /ping). Requests with no header still work.So: do not send this header from staging with your live key. Use a separate key for staging, or leave the header off there.
Everything that happens in your store goes to one address: POST /api/v1/events.
The body is a source (any name for your app) and a list of events:
BODY='{
"source":"my-store",
"events":[
{
"name":"product_viewed",
"email":"buyer@example.com",
"occurredAt":"2026-09-24T16:50:00Z",
"dedupeKey":"pv-LUMO-1-buyer",
"payload":{
"product_id":"LUMO-1",
"name":"Lumo Vial",
"url":"https://yourstore.com/p/lumo-1",
"price":4900,
"in_stock":true
}
}
]
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/events \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/events', {
source: 'my-store',
events: [{
name: 'product_viewed',
email: 'buyer@example.com',
occurredAt: new Date().toISOString(),
dedupeKey: 'pv-LUMO-1-buyer',
payload: {
product_id: 'LUMO-1',
name: 'Lumo Vial',
url: 'https://yourstore.com/p/lumo-1',
price: 4900,
in_stock: true,
},
}],
})
| Field | What it is |
|---|---|
name |
Required. One of the standard names below, or your own. |
email or contactId |
Who it was. Use one of these to tie the event to a person. |
anonymousId |
A visitor id you choose, 8 to 200 characters, for someone you do not know yet. |
occurredAt |
When it happened, as an ISO date. A date we cannot read is rejected. |
dedupeKey |
Recommended. Send the same key twice and the second is ignored. |
payload |
An object, up to 32 KB. Keys we do not know are kept. |
Once the key and signature are good, a batch always answers 200, with one result per event, in the same order:
{
"received": 1,
"results": [
{
"index": 0,
"status": "accepted",
"eventId": "8c53…",
"contactId": "d653…",
"deduplicated": false
}
]
}
Each result's status is one of:
accepted — stored and tied to a person.anonymous — stored against the anonymousId, no person yet.duplicate — we already had it.rejected — not stored. A reason says why.Read results. Do not resend the whole batch because one event was rejected. The others were stored. Fix the one and send it on its own.
413 with "101 events, over the 100 limit. Split the batch and send it again." Split it and send again.429 with a Retry-After header. Wait that many seconds, then carry on.There are sixteen standard names. Use them exactly as written — we store them as sent, and the ready-made flows listen for them.
| Event | Send it when |
|---|---|
page_visited |
Someone opens a page. |
product_viewed |
Someone opens a product. |
collection_viewed |
Someone opens a category or collection. |
search_submitted |
Someone searches. |
added_to_cart |
Something goes into the cart. |
cart_updated |
The cart changes. |
cart_emptied |
The cart is emptied. |
checkout_started |
Someone reaches checkout. |
order_created |
An order is placed. |
order_paid |
It is paid. |
order_fulfilled |
It ships. |
order_cancelled |
It is cancelled. |
order_refunded |
It is refunded. |
customer_created |
A customer account is made. |
signed_up |
Someone signs up with a form. |
form_submitted |
Someone sends any other form. |
Every amount is a whole number of cents (or the smallest unit of your currency), with a currency. $49.00 is 4900. Do not send 49.00.
For the order events, we keep one of each event per order_id. Send the same order_created twice and the second is absorbed, whatever dedupeKey you send. That makes a retry safe.
Each example below is one item in the events list. Add email (or anonymousId for a visitor you do not know yet), occurredAt and dedupeKey as described above.
Most events can also carry an attribution object in the payload: page_url, referrer, utm (source, medium, campaign, term, content, id), anon_id and tz.
page_visited
{
"name": "page_visited",
"email": "buyer@example.com",
"payload": {
"attribution": {"page_url": "https://yourstore.com/", "referrer": null}
}
}
product_viewed
{
"name": "product_viewed",
"email": "buyer@example.com",
"payload": {
"product_id": "LUMO-1",
"sku": "LUMO-BPC-5",
"name": "Lumo Vial",
"url": "https://yourstore.com/p/lumo-1",
"price": 4900,
"in_stock": true
}
}
collection_viewed
{
"name": "collection_viewed",
"email": "buyer@example.com",
"payload": {
"collection": "peptides",
"attribution": {"page_url": "https://yourstore.com/c/peptides"}
}
}
search_submitted
{
"name": "search_submitted",
"email": "buyer@example.com",
"payload": {"query": "bpc"}
}
added_to_cart — the whole cart, plus the item just added in added.
{
"name": "added_to_cart",
"email": "buyer@example.com",
"payload": {
"cart_token": "cart-123",
"items": [
{
"product_id": "LUMO-1",
"sku": "LUMO-BPC-5",
"name": "Lumo Vial",
"quantity": 2,
"unit_price": 4900,
"line_total": 9800,
"url": "https://yourstore.com/p/lumo-1",
"image_url": "https://yourstore.com/i.png"
}
],
"item_count": 2,
"money": {
"currency": "USD",
"subtotal": 9800,
"discount": 0,
"shipping": 500,
"tax": 0,
"total": 10300
},
"recovery_url": "https://yourstore.com/cart?r=1",
"added": {
"product_id": "LUMO-1",
"sku": "LUMO-BPC-5",
"name": "Lumo Vial",
"quantity": 2,
"unit_price": 4900,
"line_total": 9800
}
}
}
cart_updated — the whole cart as it is now.
{
"name": "cart_updated",
"email": "buyer@example.com",
"payload": {
"cart_token": "cart-123",
"items": [
{
"product_id": "LUMO-1",
"name": "Lumo Vial",
"quantity": 2,
"unit_price": 4900,
"line_total": 9800
}
],
"item_count": 2,
"money": {"currency": "USD", "subtotal": 9800, "total": 10300},
"recovery_url": "https://yourstore.com/cart?r=1",
"cart_url": "https://yourstore.com/cart",
"checkout_url": "https://yourstore.com/checkout"
}
}
cart_emptied
{
"name": "cart_emptied",
"email": "buyer@example.com",
"payload": {"cart_token": "cart-123", "previous_item_count": 2}
}
checkout_started — the cart, plus a checkout id.
{
"name": "checkout_started",
"email": "buyer@example.com",
"payload": {
"cart_token": "cart-123",
"checkout_token": "co-123",
"email_captured": true,
"items": [
{
"product_id": "LUMO-1",
"name": "Lumo Vial",
"quantity": 2,
"unit_price": 4900,
"line_total": 9800
}
],
"item_count": 2,
"money": {"currency": "USD", "subtotal": 9800, "total": 10300},
"recovery_url": "https://yourstore.com/cart?r=1"
}
}
order_created — the full order.
{
"name": "order_created",
"email": "buyer@example.com",
"payload": {
"order_id": "LUMO-1001",
"order_number": "1001",
"status": "processing",
"placed_at": "2026-09-24T16:50:00Z",
"items": [
{
"product_id": "LUMO-1",
"sku": "LUMO-BPC-5",
"name": "Lumo Vial",
"quantity": 2,
"unit_price": 4900,
"line_total": 9800
}
],
"item_count": 2,
"money": {
"currency": "USD",
"subtotal": 9800,
"discount": 0,
"shipping": 500,
"tax": 0,
"total": 10300
}
}
}
order_paid
{
"name": "order_paid",
"email": "buyer@example.com",
"payload": {
"order_id": "LUMO-1001",
"order_number": "1001",
"status": "processing",
"placed_at": "2026-09-24T16:50:00Z",
"items": [],
"item_count": 2,
"money": {"currency": "USD", "total": 10300}
}
}
order_fulfilled — the order, plus the shipment.
{
"name": "order_fulfilled",
"email": "buyer@example.com",
"payload": {
"order_id": "LUMO-1001",
"order_number": "1001",
"status": "completed",
"placed_at": "2026-09-24T16:50:00Z",
"items": [],
"item_count": 2,
"money": {"currency": "USD", "total": 10300},
"fulfillment_id": "F1",
"tracking_numbers": ["1Z999"]
}
}
order_cancelled
{
"name": "order_cancelled",
"email": "buyer@example.com",
"payload": {
"order_id": "LUMO-1001",
"order_number": "1001",
"status": "cancelled",
"placed_at": "2026-09-24T16:50:00Z",
"items": [],
"item_count": 2,
"money": {"currency": "USD", "total": 10300}
}
}
order_refunded — the order, plus the refund, in cents.
{
"name": "order_refunded",
"email": "buyer@example.com",
"payload": {
"order_id": "LUMO-1001",
"order_number": "1001",
"status": "refunded",
"placed_at": "2026-09-24T16:50:00Z",
"items": [],
"item_count": 2,
"money": {"currency": "USD", "total": 10300},
"refund_id": "R1",
"refund_amount": 10300,
"is_partial_refund": false
}
}
customer_created
{
"name": "customer_created",
"email": "buyer@example.com",
"payload": {
"customer_id": "cust-123",
"accepts_marketing": false,
"source": "nextjs_signup"
}
}
signed_up — a consent block here is ignored. See "Contacts and consent".
{
"name": "signed_up",
"email": "buyer@example.com",
"payload": {"form_id": "footer", "placement": "footer"}
}
form_submitted
{
"name": "form_submitted",
"email": "buyer@example.com",
"payload": {"form_id": "contact-us", "fields": {"message": "hi"}}
}
In the short order examples above, items is left empty to save space. Send the real items.
You can send any name you like, such as quiz_completed. It is stored as sent, and it shows up by itself in the list of things that can start a flow. Nothing to set up.
POST /api/v1/contacts makes a person, or updates one we already have. Up to 100 per request. attributes are merged with what we already hold, not replaced.
BODY='{
"contacts":[
{
"email":"buyer@example.com",
"firstName":"Sam",
"lastName":"Lee",
"phone":"+15555550123",
"attributes":{"plan":"gold"},
"source":"my-store"
}
]
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/contacts \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/contacts', {
contacts: [{
email: 'buyer@example.com',
firstName: 'Sam',
lastName: 'Lee',
phone: '+15555550123',
attributes: { plan: 'gold' },
source: 'my-store',
}],
})
Each result is upserted (with created: true or false) or rejected with a reason, such as an address that is not a usable email.
Adding a contact never gives permission to send them offers. Only the consent call below does that.
If you tracked someone with an anonymousId before you knew who they were, POST /api/v1/identify joins the two. It never writes consent.
BODY='{
"anonymousId":"visitor-123456",
"email":"buyer@example.com",
"tz":"America/New_York"
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/identify \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/identify', {
anonymousId: 'visitor-123456',
email: 'buyer@example.com',
tz: 'America/New_York',
})
POST /api/v1/consent is the only way to record that someone agreed to hear from you. A consent block inside a signed_up event is ignored.
Send it when someone ticks a box. Send the exact words the box showed — we keep them with the record as proof.
BODY='{
"email":"buyer@example.com",
"scope":"email_marketing",
"checkbox":{
"rendered":true,
"checked":true,
"wordingShown":"Email me news and offers."
},
"sourceKind":"checkout",
"ip":"203.0.113.9",
"userAgent":"Mozilla/5.0"
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/consent \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/consent', {
email: 'buyer@example.com',
scope: 'email_marketing',
checkbox: {
rendered: true,
checked: true,
wordingShown: 'Email me news and offers.',
},
sourceKind: 'checkout',
ip: '203.0.113.9',
userAgent: 'Mozilla/5.0',
})
The rules:
scope is required: email_marketing, email_transactional, sms_marketing or sms_transactional.checkbox is either null, which records nothing, or { "rendered": true, "checked": true or false, "wordingShown": "the exact text" }. rendered must be true.scope: "sms_marketing" with the phone. If you checked their age, add "dobVerified": true and "minAge": 21.{"written": "consent"}. With checkbox: null it is {"written": "none"}.email_marketing tick answers {"written": "pending"} and we send them the confirmation email.email_marketing box also adds the person to your default list. If you have a welcome flow on that list, it starts.POST /api/v1/catalog adds or updates your products, so emails can show them. Up to 100 per request. externalId is required — it is your own product id.
BODY='{
"products":[
{
"externalId":"LUMO-1",
"sku":"LUMO-BPC-5",
"title":"Lumo Vial 5mg",
"url":"https://yourstore.com/p/lumo-1",
"imageUrl":"https://yourstore.com/i.png",
"price":4900,
"currency":"USD",
"published":true,
"inStock":true,
"stockQty":12
}
]
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/catalog \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/catalog', {
products: [{
externalId: 'LUMO-1',
sku: 'LUMO-BPC-5',
title: 'Lumo Vial 5mg',
url: 'https://yourstore.com/p/lumo-1',
imageUrl: 'https://yourstore.com/i.png',
price: 4900,
currency: 'USD',
published: true,
inStock: true,
stockQty: 12,
}],
})
Send prices in whole cents, like everything else. If you send a decimal such as 49.99, we read it as dollars, store 4999, and put a warning on that product's result telling you to send cents.
We also accept external_id, name, image_url, in_stock and inventory_quantity, and items in place of products.
To load past orders and events, add ?mode=backfill to the events address. Everything is stored, so your numbers and segments are right. No flow starts from it. Nobody gets an abandoned-cart email about a cart from last year.
BODY='{
"source":"my-store",
"events":[
{
"name":"order_created",
"email":"buyer@example.com",
"occurredAt":"2025-03-02T10:00:00Z",
"payload":{
"order_id":"LUMO-0042",
"order_number":"42",
"status":"completed",
"placed_at":"2025-03-02T10:00:00Z",
"items":[],
"item_count":1,
"money":{"currency":"USD","total":4900}
}
}
]
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS "https://app.aminoengine.com/api/v1/events?mode=backfill" \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/events?mode=backfill', {
source: 'my-store',
events: pastOrders,
})
Here pastOrders is your own list of up to 100 events, shaped exactly like the examples above.
The same 100-per-request limit applies. Backfill has its own, lower limit: 60 requests a minute.
Send real occurredAt dates. An event dated before a flow was switched on never starts that flow, even without backfill mode.
POST /api/v1/send sends a single email — an order confirmation, a shipping notice. You need a verified sending domain first; without one the answer is 422 with no_verified_domain.
Required: to and subject, plus the email's html. Add text for a plain version. idempotencyKey stops a retry sending it twice.
BODY='{
"to":{"email":"buyer@example.com","name":"Sam Lee"},
"subject":"Your order is confirmed",
"html":"Your order LUMO-1001 is confirmed.",
"text":"Your order LUMO-1001 is confirmed.",
"idempotencyKey":"LUMO-1001:confirmed"
}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" \
| openssl dgst -sha256 -hmac "$AE_KEY" -hex | sed 's/^.* //')
curl -sS https://app.aminoengine.com/api/v1/send \
-H "Authorization: Bearer $AE_KEY" \
-H "Content-Type: application/json" \
-H "X-AE-Timestamp: $TS" \
-H "X-AE-Signature: sha256=$SIG" \
--data "$BODY"
await ae('POST', '/api/v1/send', {
to: { email: 'buyer@example.com', name: 'Sam Lee' },
subject: 'Your order is confirmed',
html: 'Your order LUMO-1001 is confirmed.',
text: 'Your order LUMO-1001 is confirmed.',
idempotencyKey: 'LUMO-1001:confirmed',
})
The answer:
{"messageId": "25c9…", "state": "queued", "deduplicated": false}
To see what happened to it, ask for the message by id — GET /api/v1/messages/{messageId}, signed like any GET:
await ae('GET', `/api/v1/messages/${messageId}`)
It answers with its state (such as sent) and a timeline of what happened. Forgetting the subject gets 422 with no_subject.
There is more on this in "Send a receipt from your own code".
Our browser script tracks page views, product views and carts from the visitor's browser, including page changes in a single-page app like Next.js. It uses a separate public key that starts with ae_pub_. A public key can only report browsing. It cannot name a person by email, write consent or create contacts. It is meant to sit in your page source, so it is not a secret.
https://yourstore.com.<head> of every page", press Copy.<head> of every page. In Next.js, that is your root layout.An owner or admin of the account can do this. The tag you copy looks like this, with your own key in it:
<script data-cfasync="false">window.ae=window.ae||function(){(window.ae.q=window.ae.q||[]).push(arguments)};</script>
<script async data-cfasync="false" src="https://app.aminoengine.com/ae.js" data-key="ae_pub_YOUR-KEY"></script>
Copy it from the screen rather than from here. If your own tracking address is set up, the screen's tag loads the script from there instead.
The site address is what stops the browser console showing a red CORS error on every event. You can leave it empty to get the tag first, and add or change it later on the same card with Save. Without it, events are still stored, but the error is noisy and the script sends each one a second time. Subdomains of the address also work.
Once the tag is on the page, send an event from your own code like this:
window.ae('track', 'product_viewed', {
product_id: 'LUMO-1',
name: 'Lumo Vial',
price: 4900,
})
Things to know:
page_visited, product_viewed, collection_viewed, search_submitted, added_to_cart, cart_updated, signed_up and identify. Anything about an order is refused there. Send orders from your server.messageId (see "Send one email").If an event is missing, read the results in the answer to the request that sent it. A rejected event always says why.
X-AE-Site-URL from this server, or use a new key.Retry-After.?mode=backfill, that its occurredAt is after the flow was switched on, and that the event name matches the flow's trigger exactly.Still stuck? Email support@aminoengine.com — we answer within one business day.