Skip to main content

How to send orders to OuterSignal with the API

Get your API key, send orders, add your product catalog, and handle customer data deletion requests.

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

  1. In OuterSignal, go to Settings > Data Sources.

  2. Click Add Data Source.

  3. On the Custom API card, click Generate API Key.

  4. Give it a Data Source Name (for example, "Website orders"), then click Generate API Key.

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

order_id

Required

"WC-98712"

order_number

Required

"#4091"

created_at

Required

"2024-03-15T10:30:00Z"

customer.email

Required unless you send customer.customer_id

customer.customer_id

Required unless you send customer.email

"CUST-44201"

An address

Required for standard search (see below)

shipping_address or billing_address

customer.first_name

Optional (defaults to the name part of the email, or "Customer")

"Jamie"

customer.last_name

Optional

"Rivera"

customer.phone

Optional

"+1-555-867-5309"

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.

shipping_address, and billing_address too if it's different

Zip-only

You only have a zip code.

"search_type": "zip_only", customer.zip, and shipping_address.country_code

Email-only

You have no address or zip at all. Lowest match rates.

"search_type": "email_only" and customer.email

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, tags

  • Products: lineItems

  • Discounts: discountCodes

  • Customer: 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

title

Product name shown in analytics

"Premium Widget Pro"

quantity

Units purchased

2

sku

Product matching and image lookup

"WIDGET-PRO-001"

variantTitle

Variant shown next to the product

"Large / Midnight Blue"

originalUnitPriceSet

Unit price before discounts

{ "shopMoney": { "amount": "99.99", ... } }

discountedUnitPriceSet

Unit price after discounts

{ "shopMoney": { "amount": "94.99", ... } }

discountedTotalSet

Line total

{ "shopMoney": { "amount": "189.98", ... } }

price

Plain per-unit price, only used if no price set is sent

"94.99"

A few things to know:

  • The product name goes in title. A name key 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_items instead of lineItems, and line_item_title, line_item_sku, line_item_quantity, line_item_id, line_item_variant_title for 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

customer.amountSpent

Lifetime spend

customer.numberOfOrders

Order count

Both together

Average order value

customer.createdAt

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

totalPriceSet, subtotalPriceSet, totalShippingPriceSet, totalTaxSet, totalDiscountsSet, currentTotalPriceSet

Order status

displayFinancialStatus (e.g. "PAID"), displayFulfillmentStatus (e.g. "FULFILLED"), processedAt, closedAt, cancelledAt, cancelReason ("CUSTOMER", "FRAUD", "INVENTORY"), confirmed

Line items

id, customAttributes (e.g. [{ "key": "gift", "value": "true" }]), plus the fields in the products table above

Discounts

discountCodes, discountApplications

Refunds

refunds

Customer

customer.updatedAt, customer.lifetimeDuration, customer.defaultAddress, customer.statistics

Marketing attribution

customerJourney.firstVisit, customerJourney.lastVisit, customerJourney.moments

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 200 with the message "Order correction received and queued for processing".

  • You can correct the same order_id up to 10 times in 24 hours. After that you'll get 429 TOO_MANY_CORRECTIONS with a Retry-After header 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

200

Order received (or correction received).

Nothing.

400

A required field or address is missing. The response lists what's missing.

Fix the payload and resend.

401

The API key is missing or wrong.

Check the X-API-Key header.

409

We already have this exact order.

Treat it as "already received." No action needed.

413

The request is over 5 MB.

Trim the payload.

422

The order's country isn't the US or Australia.

We don't import it. If the country was wrong, resend the same order_id with the correct one.

429

You've corrected this order more than 10 times in 24 hours.

Wait for the time in Retry-After.

500

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 same order_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

  1. Send orders as soon as they're placed. That's how VIPs show up fastest.

  2. Send complete orders every time, with line items and customer totals. A correction replaces the whole order.

  3. Send as much customer and address data as you have. More data means better match rates.

  4. Use standard search whenever you have an address, and include billing if it's different from shipping.

  5. Reuse the same order_id for retries, and back off between attempts.

  6. 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-Key header as for orders.

  • Different address: catalog requests go to https://app.outersignal.com, not api.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

id

Required

Your product ID. 1–255 characters, using only letters, numbers, and . _ ~ -. Can't be . or ...

title

Required

Up to 500 characters, exactly as it appears on your orders.

image_url

Optional

An https link to the image on your own CDN, up to 2,048 characters. We store the link, not a copy of the image.

product_type

Optional

For example "Supplements". Up to 255 characters.

tags

Optional

Up to 250 tags, each up to 255 characters.

variants

Optional

Up to 100, each { "title", "sku", "price" }. Price is a plain number as text, like "19.99", with no currency symbol. Variant titles use the same Size / Color format as your line items.

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 429 with a Retry-After header.

Catalog errors

Status

What it means

400

Something in the body is invalid: a field over its limit, a badly formed ID or price, an image link that isn't https, or a repeated ID or title. Nothing is saved.

401

The X-API-Key is missing or not recognized.

403

The key belongs to a Shopify or Stripe data source, which can't send a catalog.

409

The product conflicts with an existing catalog product. Contact support, since retrying won't help.

429

You've hit the hourly limit. Wait the number of seconds in Retry-After.

500

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:

  • deleted is how many records were deleted. by_email and by_phone split that by what matched.

  • deleted_ids lists each record deleted by an email or phone match. Some of a customer's data can be erased without showing up in deleted or deleted_ids, so deleted: 0 doesn't mean nothing was removed. Keep the request you sent and its 200 response as your record of the deletion.

  • skipped_count above 0 means a matching record couldn't be deleted, and other matches may remain even when complete is true. Resending won't change that. Contact support.

  • complete: false means more matching records are left. Send the same request again until it says true.

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

400

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.

401

The Authorization header is missing, or the key is unknown, expired, or isn't a deletion key, or the person who created the key is no longer active in your account.

403

Your account isn't set up for deletion requests yet. Contact support.

429

You've hit the rate limit. Wait the number of seconds in Retry-After.

500

Something failed on our side. Retry with backoff; resending is safe.

503

A temporary problem stopped the request before anything was deleted. Send it again after the number of seconds in Retry-After.

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.

Did this answer your question?