If your store isn't on Shopify, or you want to send orders from your own system, you can use the OuterSignal API. This guide covers how to get your API key, send your first order, and what to send so your analytics fill in correctly. It also covers the Catalog API for product images and collections, and how to delete a customer's data.
Who this is for: the developer or technical lead connecting your store or order system to OuterSignal.
Before you start
Plan: API access is on the Scale plan. Contact sales to upgrade.
Countries: OuterSignal only imports orders from the US and Australia. Orders from other countries are turned away (see Errors below).
Already on Shopify? You don't need the API for orders. Your Shopify connection handles that.
Step 1: Get your API key
In OuterSignal, go to Settings > Data Sources.
Click Add Data Source.
On the Custom API card, click Generate API Key.
Give it a Data Source Name (for example, "Website orders"), then click Generate API Key.
Copy the key and store it somewhere safe. It's only shown once.
If you lose the key, you can generate a new one from that data source's settings. The old key stops working when you do.
Keep your key private. Never put it in code that runs in a browser or in a public code repository.
Step 2: Send your first order
Send each order as a POST request:
POST https://api.outersignal.com/webhook/search
Content-Type: application/json
X-API-Key: your-api-key-here
Here's a test you can run right away. Replace your-api-key-here with your key:
curl -X POST https://api.outersignal.com/webhook/search \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"order_id": "TEST-001",
"order_number": "#TEST-001",
"created_at": "2024-01-20T12:00:00Z",
"customer": {
"email": "[email protected]",
"first_name": "Test",
"last_name": "Customer"
},
"shipping_address": {
"first_name": "Test",
"last_name": "Customer",
"address1": "123 Test Street",
"city": "Test City",
"state_code": "CA",
"country_code": "US",
"zip": "90001"
}
}'
If it worked, you'll get a 200 response like this:
{
"success": true,
"message": "Order received and queued for processing",
"order_id": "TEST-001",
"tags_imported": 0
}Send one order per request. There's no batch endpoint.
What to send
Required fields
Field | Required? | Example |
| Required |
|
| Required |
|
| Required |
|
| Required unless you send | |
| Required unless you send |
|
An address | Required for standard search (see below) |
|
| Optional (defaults to the name part of the email, or "Customer") |
|
| Optional |
|
| Optional |
|
Choosing a search mode
How we find the person behind an order depends on what address data you have. There are three modes:
Mode | When to use it | What to send |
Standard (default) | You have a full address. Best match rates. |
|
Zip-only | You only have a zip code. |
|
Email-only | You have no address or zip at all. Lowest match rates. |
|
Send an address whenever you have one. When the shipping and billing addresses are different, include both. It gives us more to match on.
Zip-only example:
{
"order_id": "ORD-12347",
"order_number": "#1003",
"search_type": "zip_only",
"created_at": "2024-01-16T10:15:00Z",
"customer": {
"email": "[email protected]",
"first_name": "Tom",
"last_name": "Watkins",
"zip": "10005"
},
"shipping_address": { "country_code": "US" }
}Email-only example:
{
"order_id": "ORD-12348",
"order_number": "#1004",
"search_type": "email_only",
"created_at": "2024-01-16T11:20:00Z",
"customer": { "email": "[email protected]" }
}Recommended fields for analytics
The required fields are enough to identify a customer. The fields below are what fill in your revenue, product, and customer analytics. The most useful ones are:
Order:
totalPriceSet,displayFinancialStatus,displayFulfillmentStatus,tagsProducts:
lineItemsDiscounts:
discountCodesCustomer:
customer.amountSpent,customer.numberOfOrders,customer.createdAt,customer.tags
Here's a fuller order with the most common fields:
{
"order_id": "ORD-98765",
"order_number": "#2050",
"created_at": "2024-03-15T10:30:00Z",
"tags": ["wholesale", "priority"],
"displayFinancialStatus": "PAID",
"displayFulfillmentStatus": "FULFILLED",
"totalPriceSet": { "shopMoney": { "amount": "284.97", "currencyCode": "USD" } },
"subtotalPriceSet": { "shopMoney": { "amount": "259.97", "currencyCode": "USD" } },
"totalShippingPriceSet": { "shopMoney": { "amount": "15.00", "currencyCode": "USD" } },
"totalTaxSet": { "shopMoney": { "amount": "20.00", "currencyCode": "USD" } },
"customer": {
"email": "[email protected]",
"first_name": "Sarah",
"last_name": "Chen",
"createdAt": "2023-06-01T08:00:00Z",
"amountSpent": { "amount": "4829.50", "currencyCode": "USD" },
"numberOfOrders": 23,
"tags": ["vip", "repeat-buyer"]
},
"shipping_address": {
"first_name": "Sarah",
"last_name": "Chen",
"address1": "742 Evergreen Terrace",
"city": "Portland",
"state_code": "OR",
"country_code": "US",
"zip": "97201"
},
"lineItems": [
{
"title": "Premium Widget Pro",
"quantity": 2,
"sku": "WIDGET-PRO-001",
"originalUnitPriceSet": { "shopMoney": { "amount": "99.99", "currencyCode": "USD" } }
}
],
"discountCodes": ["SPRING10"]
}Products (line items)
Send products as a lineItems array on the order.
Field | What it's used for | Example |
| Product name shown in analytics |
|
| Units purchased |
|
| Product matching and image lookup |
|
| Variant shown next to the product |
|
| Unit price before discounts |
|
| Unit price after discounts |
|
| Line total |
|
| Plain per-unit price, only used if no price set is sent |
|
A few things to know:
The product name goes in
title. Anamekey isn't read.If you send both the original and discounted price, the original is recorded as the price and the difference as the discount. If you only send the discounted price, that's used as the price.
If your system already exports snake_case, that works too:
line_itemsinstead oflineItems, andline_item_title,line_item_sku,line_item_quantity,line_item_id,line_item_variant_titlefor the item fields. The array can also be wrapped as{ "nodes": [...] }. If both forms are present, the camelCase version wins.
Customer lifetime totals
These fields set the lifetime numbers on customer profiles. Send both totals or neither. If you leave both out, we add up the orders you send, counting each order_id once.
Field | What it fills in |
| Lifetime spend |
| Order count |
Both together | Average order value |
| Customer-since date |
When you do send the totals, the most recent order's totals replace what the profile shows, even if they're lower. So send the customer's current running totals from your system with every order.
Tags
Order tags and customer.tags both accept either a list (["wholesale", "priority"]) or a single string. A string is split on pipes ("wholesale|priority"), or on commas if there are no pipes ("wholesale,priority").
Tags are lowercased and trimmed.
Up to 20 tags are kept, each cut at 50 characters.
Order tags and customer tags are stored separately, so you can filter on each.
tags_imported in the response is the total number of order and customer tags we accepted.
All other optional fields
Everything else is optional. The more you send, the more complete your analytics.
Group | Fields |
Order totals |
|
Order status |
|
Line items |
|
Discounts |
|
Refunds |
|
Customer |
|
Marketing attribution |
|
All money fields use this shape:
{ "shopMoney": { "amount": "284.97", "currencyCode": "USD" } }Shapes for discounts, refunds, and marketing attribution
discountApplications item:
{
"code": "SPRING10",
"allocationMethod": "ACROSS",
"targetSelection": "ALL",
"targetType": "LINE_ITEM",
"value": { "percentage": 10.0 }
}refunds item:
{
"id": "refund_001",
"createdAt": "2024-01-20T10:00:00Z",
"totalRefundedSet": { "shopMoney": { "amount": "49.99", "currencyCode": "USD" } },
"refundLineItems": [
{ "quantity": 1, "lineItem": { "title": "Premium Widget", "sku": "WIDGET-001" } }
]
}customerJourney visit (firstVisit, lastVisit, and each item in moments):
{
"occurredAt": "2024-03-10T14:20:00Z",
"landingPage": "/collections/new-arrivals",
"referrerUrl": "https://www.instagram.com/",
"source": "instagram",
"sourceType": "social",
"utmParameters": {
"source": "instagram",
"medium": "paid_social",
"campaign": "spring_launch"
}
}Correcting an order
To fix an order, send it again with the same order_id and the complete corrected order, including line items and customer. The new version replaces the old one. Anything you leave out is not carried over from the original, so always send the full order, not just what changed.
A correction gets a
200with the message"Order correction received and queued for processing".You can correct the same
order_idup to 10 times in 24 hours. After that you'll get429 TOO_MANY_CORRECTIONSwith aRetry-Afterheader telling you how long to wait.A correction has to pass the data source's import filters. If it doesn't, the original order is kept as it was.
Customer lifetime totals on a correction are only applied if they're not older than what we already have.
Responses and errors
Status | What it means | What to do |
| Order received (or correction received). | Nothing. |
| A required field or address is missing. The response lists what's missing. | Fix the payload and resend. |
| The API key is missing or wrong. | Check the |
| We already have this exact order. | Treat it as "already received." No action needed. |
| The request is over 5 MB. | Trim the payload. |
| The order's country isn't the US or Australia. | We don't import it. If the country was wrong, resend the same |
| You've corrected this order more than 10 times in 24 hours. | Wait for the time in |
| Something failed on our side. | Retry with backoff; resending is safe. |
Example error responses:
// 400 — missing field
{
"success": false,
"validationStatus": "INVALID",
"errorMessage": "Missing required fields: created_at",
"httpStatusCode": 400,
"errorDetails": { "missing_fields": ["created_at"] }
}
// 401 — bad key
{ "success": false, "error": "Invalid API key" }
// 409 — duplicate
{
"success": false,
"error": "DUPLICATE_ORDER_ID",
"message": "Order ID 'ORD-12345' already exists for this channel"
}
If an address is missing in standard mode, the 400 message reads: "at least one address (shipping_address or billing_address) is required, or send search_type "zip_only" with a customer.zip, or search_type "email_only" with a customer.email".
Duplicates and retries
Sending the exact same order again within 24 hours of the last accepted send returns
409. Repeats don't reset that 24-hour window. After the window closes, a resend is accepted (an identical one changes nothing).This means retries are safe: if a request fails with a network error or a
5xx, resend it with the sameorder_id, backing off a little more each time (for example 1s, 2s, 4s, up to a cap).Duplicate detection is best effort, so on rare occasions a resend can be accepted twice. Build your side to tolerate an occasional duplicate.
Volume
There's no fixed request-rate limit per key. If you're planning to send a high volume continuously, like a large backfill, let us know first so we can plan for it.
Each request has 8 seconds to process. One that runs longer fails with a 500, so retry it the same way.
Best practices
Send orders as soon as they're placed. That's how VIPs show up fastest.
Send complete orders every time, with line items and customer totals. A correction replaces the whole order.
Send as much customer and address data as you have. More data means better match rates.
Use standard search whenever you have an address, and include billing if it's different from shipping.
Reuse the same
order_idfor retries, and back off between attempts.Keep your API key on your server, never in browser code or a public repository.
Catalog API: product images and collections
Orders don't carry product images. If you don't have a connected Shopify store, send your products and collections with the Catalog API so images appear across your analytics and you can track collections in Product Intelligence.
Same key: use the same
X-API-Keyheader as for orders.Different address: catalog requests go to
https://app.outersignal.com, notapi.outersignal.com.Shopify stores sync their catalog from Shopify and can't use these endpoints.
The product title must match your orders exactly. We match products to order line items by title, character for character. A different spelling, spacing, or capitalization counts as a different product, and its image won't show on your orders.
Send products
POST https://app.outersignal.com/api/catalog/products
Field | Required? | Details |
| Required | Your product ID. 1–255 characters, using only letters, numbers, and |
| Required | Up to 500 characters, exactly as it appears on your orders. |
| Optional | An |
| Optional | For example |
| Optional | Up to 250 tags, each up to 255 characters. |
| Optional | Up to 100, each |
curl -X POST https://app.outersignal.com/api/catalog/products \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"products": [
{
"id": "prod_1001",
"title": "Premium Widget Pro",
"image_url": "https://cdn.example.com/widget-pro.jpg",
"product_type": "Widgets",
"tags": ["bestseller"],
"variants": [
{ "title": "Large / Midnight Blue", "sku": "WIDGET-PRO-L-BLU", "price": "99.99" }
]
}
]
}'
Response:
{
"data": {
"created": 1,
"updated": 0,
"products": [{ "id": "prod_1001", "product_id": "5b1f0c2e-7d7a-4c1e-9a4f-2f3b8c6d9e10" }],
"skipped": []
}
}skipped lists any products whose title already belongs to a connected Shopify store. Shopify stays the source for those.
Resending a product: the image, type, and tags are replaced, and leaving one out clears it. Variants work differently: a resend only replaces the variants you sent before, and variants we picked up from your orders are always kept. A request that repeats the same id or title twice is rejected with 400.
Send collections
POST https://app.outersignal.com/api/catalog/collections
Each collection has an id, a title (up to 255 characters), and the product_ids in it. Collection IDs follow the same rules as product IDs. Send your products first: any product IDs we don't recognize come back in unknown_product_ids and are left out.
curl -X POST https://app.outersignal.com/api/catalog/collections \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"collections": [
{ "id": "summer-drop", "title": "Summer Drop", "product_ids": ["prod_1001", "prod_1002"] }
]
}'
Response:
{
"data": {
"collections": [
{ "id": "summer-drop", "mapped": 1, "unknown_product_ids": ["prod_1002"] }
]
}
}Resending a collection replaces its product list with the one you send. Collections are saved one at a time in the order you send them, so if a request fails partway, the ones before the failure are saved. It's safe to resend the whole request.
Delete a collection
DELETE https://app.outersignal.com/api/catalog/collections/{id}Returns { "data": { "deleted": true } }, or false if no collection had that ID. If Product Intelligence was tracking it, it shows as deleted there.
IDs belong to each API key
Product and collection IDs belong to the data source whose key sent them. Two data sources can use the same IDs without affecting each other, and a collection can only include products sent with the same key. Product titles are shared across your whole account, though: one title is one product, whichever data source sent it.
Catalog limits
500 products per request
50 collections per request, and 5,000 product IDs per collection
100 variants per product
500 requests per hour per API key. Over that, you'll get
429with aRetry-Afterheader.
Catalog errors
Status | What it means |
| Something in the body is invalid: a field over its limit, a badly formed ID or price, an image link that isn't |
| The |
| The key belongs to a Shopify or Stripe data source, which can't send a catalog. |
| The product conflicts with an existing catalog product. Contact support, since retrying won't help. |
| You've hit the hourly limit. Wait the number of seconds in |
| Something failed on our side. Retry with backoff; resending is safe. |
Deleting a customer's data (GDPR / data deletion requests)
When a customer asks you to delete their data, you can remove their records from OuterSignal with one request. Send their email, phone number, or both. Matching records in your account are deleted, along with their signal data.
POST https://app.outersignal.com/api/gdpr/delete
You need a separate deletion key. The API key you generate for orders doesn't work here; it returns 401. Contact support to get a deletion key. Send it as Authorization: Bearer your-deletion-key, not X-API-Key.
curl -X POST https://app.outersignal.com/api/gdpr/delete \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-deletion-key" \
-d '{
"email": "[email protected]",
"phone": "+15551234567"
}'
At least one of email or phone is required. Phone numbers must include the country code, like +15551234567 (E.164 format).
Response (200):
{
"data": {
"deleted": 3,
"by_email": 2,
"by_phone": 1,
"skipped_count": 0,
"complete": true,
"deleted_ids": [
{ "id": "1048576", "via": "email" },
{ "id": "1048577", "via": "email" },
{ "id": "2097152", "via": "phone" }
]
}
}How to read the response:
deletedis how many records were deleted.by_emailandby_phonesplit that by what matched.deleted_idslists each record deleted by an email or phone match. Some of a customer's data can be erased without showing up indeletedordeleted_ids, sodeleted: 0doesn't mean nothing was removed. Keep the request you sent and its200response as your record of the deletion.skipped_countabove 0 means a matching record couldn't be deleted, and other matches may remain even whencompleteistrue. Resending won't change that. Contact support.complete: falsemeans more matching records are left. Send the same request again until it saystrue.
A request that matches nothing returns 200 with deleted: 0, the same as for someone who was already deleted. The response never shows whether a person exists in another account.
Deletion errors
Status | What it means |
| The body isn't valid JSON, has neither email nor phone, or has a badly formed email or a phone number without the country code. Nothing is deleted. |
| The |
| Your account isn't set up for deletion requests yet. Contact support. |
| You've hit the rate limit. Wait the number of seconds in |
| Something failed on our side. Retry with backoff; resending is safe. |
| A temporary problem stopped the request before anything was deleted. Send it again after the number of seconds in |
Errors come back with a message and a code, for example {"error": "Rate limit exceeded", "code": "RATE_LIMIT_EXCEEDED"}.
Rate limit: 2,000 requests per hour per key. Only requests that pass the key check and validation count toward it.
Still stuck?
Send us a message from the chat bubble in OuterSignal and include the order_id and the full response you got back. That's usually all we need to sort it out quickly.
