Server-Side Events
The server-side endpoint allows your backend services to submit events directly to Datafly Signal without going through the browser. This is the recommended approach for events that originate on the server, such as order completions, subscription changes, or backend-computed conversions.
Endpoint
POST /v1/eventsAuthentication
Server-side requests are authenticated using HMAC-SHA256 signatures. Every request must include two headers:
| Header | Value | Description |
|---|---|---|
X-Signature | sha256={hmac_hex} | HMAC-SHA256 signature of the request |
X-Timestamp | {unix_ms} | Current time in Unix milliseconds |
HMAC Computation
The signature is computed as:
HMAC-SHA256(secret_key, timestamp + "." + request_body)Where:
secret_key— your source’s secret key (available in the Management UI)timestamp— the value of theX-Timestampheader (Unix milliseconds as a string)request_body— the raw JSON request body
Signal rejects requests where the timestamp is more than 5 minutes from the server’s current time, in either direction, and answers 403. The timestamp is inside the signed string, so a captured request cannot be replayed once it ages out of the window. Make sure your server’s clock is synchronised via NTP.
Deprecated: an older scheme sent X-Datafly-Signature: {hmac_hex} computed over the request body alone, with no timestamp. It is still accepted so existing integrations keep working, but it carries no replay protection. Use X-Signature with X-Timestamp for new integrations. If you send both, the timestamped signature is the one that is enforced.
Request Format
Events are sent as an array under events, so one request can carry a batch. Field names are snake_case.
{
"events": [
{
"type": "track",
"event": "order_completed",
"user_id": "user_12345",
"properties": {
"order_id": "ORD-98765",
"revenue": 109.97,
"currency": "GBP",
"products": [
{ "product_id": "SKU-1234", "name": "Wireless Headphones", "price": 79.99, "quantity": 1 },
{ "product_id": "SKU-5678", "name": "USB-C Cable", "price": 14.99, "quantity": 2 }
]
},
"context": {
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ..."
},
"timestamp": "2026-02-25T14:30:00.000Z"
}
]
}Fields
Each element of events accepts:
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | track, page, identify, or group |
event | string | Track only | Event name |
user_id | string | Recommended | Known user ID |
anonymous_id | string | No | Anonymous ID (use if user_id is not available) |
properties | object | No | Event properties |
context | object | No | Contextual data (IP, user agent, etc.) |
context.ip | string | Recommended | Client IP address for geolocation enrichment |
context.user_agent | string | Recommended | Client user agent for device parsing |
timestamp | string | No | ISO 8601 timestamp; defaults to server receipt time |
message_id | string | No | Your idempotency key for the event |
event_id | string | No | Customer-supplied identifier used for cross-platform vendor deduplication |
You should provide at least one of user_id or anonymous_id. If both are present, Signal will link them together.
Response
204 No Content when every event in the batch has been accepted.
An event that cannot be accepted on its own is refused without failing the rest of the batch. The answer is then 200 with the refused events listed; every event not listed was accepted:
{
"rejected": [
{ "message_id": "evt_002", "reason": "invalid" }
]
}| Reason | Meaning |
|---|---|
invalid | The event could not be read, for example a timestamp that is not RFC 3339 or a number where text is expected. |
too_deep | Properties nested more deeply than the deployment allows. |
too_many_properties | More properties than the deployment allows. |
property_too_large | A property value longer than the deployment allows. |
too_large | The event is larger than the event queue accepts as one message (about 1 MB once Signal has added its own fields). |
Refused events are not retried: fix them and send them again with the same message_id. They appear in the Event Debugger with the reason.
Status codes:
| Status | Meaning |
|---|---|
204 | Every event was accepted. |
200 | Every event was accepted except those listed in rejected. |
400 | The body is not valid JSON, or has no events array. |
401 | The signature is invalid or missing. |
403 | The signature timestamp is outside the accepted window. |
413 | The body or the number of events is over the limit. Split the batch. |
429 | Rate-limited. Send again after Retry-After seconds. |
503 | Send the batch again after Retry-After seconds. Signal could not accept every event, an earlier request is still processing one of them, or a backend Signal needs to check the request was unavailable. |
Resending is safe
Events are de-duplicated by message_id, per pipeline, for 24 hours. When a request fails, send the same batch again with the same message_id values: events already accepted are acknowledged and not delivered twice, and the rest are accepted. An event without a message_id is given a new one on arrival, so it cannot be de-duplicated.
Examples
Each example signs {timestamp}.{body} and sends the exact bytes it signed. Serialise the body once and reuse that string — re-serialising can reorder keys and invalidate the signature.
curl
SECRET="your_pipeline_secret"
BODY='{"events":[{"type":"track","event":"order_completed","user_id":"user_12345","properties":{"order_id":"ORD-98765","revenue":109.97,"currency":"GBP"}}]}'
TIMESTAMP=$(date +%s000)
SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://collect.example.com/v1/events \
-H "Content-Type: application/json" \
-H "X-Pipeline-Key: your_pipeline_key" \
-H "X-Signature: sha256=$SIGNATURE" \
-H "X-Timestamp: $TIMESTAMP" \
-d "$BODY"Node.js
import crypto from 'node:crypto'
const SECRET = process.env.DATAFLY_PIPELINE_SECRET
const ENDPOINT = 'https://collect.example.com/v1/events'
async function send(events) {
// Serialise once and sign exactly what is sent.
const body = JSON.stringify({ events })
const timestamp = Date.now().toString()
const signature = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${body}`)
.digest('hex')
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Pipeline-Key': process.env.DATAFLY_PIPELINE_KEY,
'X-Signature': `sha256=${signature}`,
'X-Timestamp': timestamp,
},
body,
})
if (!res.ok) {
throw new Error(`Datafly ingest failed: ${res.status} ${await res.text()}`)
}
}
await send([
{
type: 'track',
event: 'order_completed',
user_id: 'user_12345',
properties: { order_id: 'ORD-98765', revenue: 109.97, currency: 'GBP' },
},
])Python
import hashlib
import hmac
import json
import os
import time
import requests
SECRET = os.environ["DATAFLY_PIPELINE_SECRET"]
ENDPOINT = "https://collect.example.com/v1/events"
def send(events):
# Serialise once and sign exactly what is sent.
body = json.dumps({"events": events}, separators=(",", ":"))
timestamp = str(int(time.time() * 1000))
signature = hmac.new(
SECRET.encode(),
f"{timestamp}.{body}".encode(),
hashlib.sha256,
).hexdigest()
response = requests.post(
ENDPOINT,
data=body,
headers={
"Content-Type": "application/json",
"X-Pipeline-Key": os.environ["DATAFLY_PIPELINE_KEY"],
"X-Signature": f"sha256={signature}",
"X-Timestamp": timestamp,
},
timeout=10,
)
response.raise_for_status()
send([
{
"type": "track",
"event": "order_completed",
"user_id": "user_12345",
"properties": {"order_id": "ORD-98765", "revenue": 109.97, "currency": "GBP"},
}
])Go
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type Event struct {
Type string `json:"type"`
Event string `json:"event,omitempty"`
UserID string `json:"user_id,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
}
func send(events []Event) error {
// Marshal once and sign exactly what is sent.
body, err := json.Marshal(map[string][]Event{"events": events})
if err != nil {
return err
}
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
mac := hmac.New(sha256.New, []byte(os.Getenv("DATAFLY_PIPELINE_SECRET")))
mac.Write([]byte(timestamp + "." + string(body)))
signature := hex.EncodeToString(mac.Sum(nil))
req, err := http.NewRequest("POST", "https://collect.example.com/v1/events", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pipeline-Key", os.Getenv("DATAFLY_PIPELINE_KEY"))
req.Header.Set("X-Signature", "sha256="+signature)
req.Header.Set("X-Timestamp", timestamp)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 204: all accepted. 200: accepted except the events listed in "rejected".
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("datafly ingest failed: %s", resp.Status)
}
return nil
}Best Practices
- Give every event a stable
message_id(your order or event ID works) so a resent batch is de-duplicated. - Always include
context.ipandcontext.user_agentwhen you have them. This enables geolocation enrichment and device parsing. - Use
user_idwhenever possible. Server-side events are most valuable when tied to a known user identity. - Set
timestampto the actual event time, not the time your server processes it. This ensures accurate attribution windows in downstream vendors. - Rotate secret keys periodically via the Management API. Signal supports key rotation with a grace period for the previous key.