Connect a custom-built store with the API

For a store that is not WooCommerce or Shopify. Send events, contacts, consent, orders and products from your own server, with a signed request.

Updated · 11 min read

Where it is in the app

Open Home, choose Settings, then General › API keys.

  • Home
  • Settings
  • General
  • API keys

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:

  • An Amino Engine account.
  • A server key, made in the app. The next section shows where.

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.

    On screenSettings › 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:

cURL
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:

  • Sign the exact bytes you send. Turn your JSON into a string once, sign that string, and send that same string. If you sign one string and send another, the signature fails.
  • A request with no body (a GET) signs the timestamp followed by a dot and nothing else.
  • The timestamp must be within 5 minutes of our clock. Keep your server's clock synced.

This small Node helper (Node 18 or newer) does all of it. The rest of this page uses it.

Node
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 request.

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"

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:

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.

  • Without it, everything on this page works.
  • The first value you ever send is kept for good. After that, a request from the same key with a different site address is refused (403, or 409 on /ping). Requests with no header still work.
  • Binding a site is what lets the browser snippet on that site work without errors. See "Browser tracking" below.
  • To move to a new domain, make a new key.

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"

#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:

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

#Limits

  • Up to 100 events per request, and a body up to 1 MB. Send 101 and you get 413 with "101 events, over the 100 limit. Split the batch and send it again." Split it and send again.
  • 600 requests a minute and 6,000 events a minute per brand.
  • Over a limit you get 429 with a Retry-After header. Wait that many seconds, then carry on.

#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
JSON
{
  "name": "page_visited",
  "email": "buyer@example.com",
  "payload": {
    "attribution": {"page_url": "https://yourstore.com/", "referrer": null}
  }
}
product_viewed
JSON
{
  "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
JSON
{
  "name": "collection_viewed",
  "email": "buyer@example.com",
  "payload": {
    "collection": "peptides",
    "attribution": {"page_url": "https://yourstore.com/c/peptides"}
  }
}
search_submitted
JSON
{
  "name": "search_submitted",
  "email": "buyer@example.com",
  "payload": {"query": "bpc"}
}

added_to_cart — the whole cart, plus the item just added in added.

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

JSON
{
  "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
JSON
{
  "name": "cart_emptied",
  "email": "buyer@example.com",
  "payload": {"cart_token": "cart-123", "previous_item_count": 2}
}

checkout_started — the cart, plus a checkout id.

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

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

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

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

JSON
{
  "name": "signed_up",
  "email": "buyer@example.com",
  "payload": {"form_id": "footer", "placement": "footer"}
}
form_submitted
JSON
{
  "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.

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

Each result is upserted (with created: true or false) or rejected with a reason, such as an address that is not a usable email.

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"

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"

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.
  • For texts, send scope: "sms_marketing" with the phone. If you checked their age, add "dobVerified": true and "minAge": 21.
  • A good answer is {"written": "consent"}. With checkbox: null it is {"written": "none"}.
  • If your account asks people to confirm by email first, an email_marketing tick answers {"written": "pending"} and we send them the confirmation email.
  • A ticked email_marketing box also adds the person to your default list. If you have a welcome flow on that list, it starts.

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

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"

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"

The answer:

JSON
{"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:

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

    On screenSettings › 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.

    On screen"Paste into the `<head › ` of every page"
  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:

HTML
<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:

Node
window.ae('track', 'product_viewed', {
  product_id: 'LUMO-1',
  name: 'Lumo Vial',
  price: 4900,
})

Things to know:

  • Only these events can come from the browser: 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.
  • Up to 20 events per post, and 120 posts a minute from one visitor.

#Check it worked

  • Settings → Other → Integrations, under "What your shop is sending", lists every event name we have received from you, with counts. Your own custom names show there too.
  • A person's page shows their consent under "Where their consent came from", with the wording they saw.
  • An email you sent can be looked up by its 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.

#If it didn't work

  • 401 saying the key is "missing, malformed, revoked, or does not exist". Almost always the signature: sign the exact string you send, use the key as the secret, and check your server's clock is within 5 minutes. If all of that is right, make a new key.
  • 403 or 409 about a different site. This key is already tied to another site address. Stop sending X-AE-Site-URL from this server, or use a new key.
  • 413. More than 100 in one request. Split it.
  • 429. Too fast. Wait the number of seconds in Retry-After.
  • A flow did not start. Check the event was not sent with ?mode=backfill, that its occurredAt is after the flow was switched on, and that the event name matches the flow's trigger exactly.
Was this helpful?

Still stuck? A person answers.

Email support@aminoengine.com and we answer within one business day.