← All help

Connect a custom-built store with the API

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.

Get a key

  1. Go to Settings → General → API keys.
  2. Press Create key.
  3. Give it a name, such as "Store", and pick when it expires.
  4. Press Create.

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'

Sign every request

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() }
}

Check the key works

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.

What a bad signature looks like

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.

Optional: tie the key to your site

You may send one more header, X-AE-Site-URL, with your store's address, such as https://yourstore.com.

So: do not send this header from staging with your live key. Use a separate key for staging, or leave the header off there.

Send events

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,
    },
  }],
})

Each event

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.

The answer

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:

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.

Limits

Event reference

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.

Money is in whole cents

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.

Orders are de-duplicated by order id

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.

Examples

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.

Your own events

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.

Contacts and consent

Add or update people

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.

Link a visitor to a person

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',
})

Record consent

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:

Product catalog

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.

Import your history

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.

Send one email

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".

Browser tracking

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.

Get the tag

  1. Go to Settings → General → API keys.
  2. Under Browser tracking, type your site's address in Your site's address, such as https://yourstore.com.
  3. Press Create browser key.
  4. Under "Paste into the <head> of every page", press Copy.
  5. Paste it into the <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.

Send your own events

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:

Check it worked

If an event is missing, read the results in the answer to the request that sent it. A rejected event always says why.

If it didn't work

Still stuck? Email support@aminoengine.com — we answer within one business day.

Last updated 2026-09-24.
Still stuck? Email support@aminoengine.com — we answer within one business day.