Ingest API · v1
POST /api/ingest/v1
Push first-party signals (page views, intent events, drop-offs) from your own backend into Wavly. HMAC-signed, idempotent, Stripe- style.
Quick start
- Create a project key at Settings → Listening and copy the
bsk_…secret on first reveal (you only see it once). - Send
POST /api/ingest/v1with the headers below and a JSON body of{ visitor_id, events: [...] }. - Verify the response:
{ ok: true, accepted: <int> }. Retry on5xx.
Headers
| Name | Required | Description |
|---|---|---|
| X-Wavly-Key | yes | Your public key (bpk_pub_…). Lets us look up your project's secret without sending it over the wire. |
| X-Wavly-Signature | yes | t=<unix_seconds>,v1=<hmac_sha256_hex>. Compute v1 = HMAC_SHA256(secret, '<timestamp>.<raw_body>'). Same recipe Stripe uses. |
| Idempotency-Key | no | Optional. Up to 128 chars. Retrying with the same key returns the same accepted count without re-inserting. TTL: 7 days. |
| Content-Type | yes | application/json |
Signature recipe
Build the signed payload as <timestamp>.<raw_body> (literal dot). HMAC-SHA256 with the secret, encode the result as lowercase hex, prepend t=…,v1=.
signed_payload = f"{timestamp}.{raw_body}"
v1 = hmac_sha256(secret, signed_payload).hex()
header = f"t={timestamp},v1={v1}"Sign the EXACT raw bytes you put on the wire. JSON re-serialization (e.g. an extra trailing newline from a pretty-printer) will break the signature.
Examples
curl
BODY='{"visitor_id":"v_abc","events":[{"event_type":"intent","url":"https://shop.example.com/checkout"}]}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$Wavly_SECRET" -hex | awk '{print $2}')
curl -sS -X POST https://thewavly.com/api/ingest/v1 \
-H "Content-Type: application/json" \
-H "X-Wavly-Key: $Wavly_PUBLIC_KEY" \
-H "X-Wavly-Signature: t=$TS,v1=$SIG" \
-H "Idempotency-Key: order-9482" \
--data-raw "$BODY"Node (built-in crypto)
import crypto from "node:crypto";
const body = JSON.stringify({
visitor_id: "v_abc",
events: [{ event_type: "intent", url: "https://shop.example.com/checkout" }],
});
const ts = Math.floor(Date.now() / 1000);
const v1 = crypto
.createHmac("sha256", process.env.Wavly_SECRET)
.update(`${ts}.${body}`)
.digest("hex");
const res = await fetch("https://thewavly.com/api/ingest/v1", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Wavly-Key": process.env.Wavly_PUBLIC_KEY,
"X-Wavly-Signature": `t=${ts},v1=${v1}`,
"Idempotency-Key": "order-9482",
},
body,
});
const json = await res.json();
console.log(json); // { ok: true, accepted: 1 }Python
import hmac, hashlib, json, os, time, requests
body = json.dumps({
"visitor_id": "v_abc",
"events": [{"event_type": "intent", "url": "https://shop.example.com/checkout"}],
}, separators=(",", ":"))
ts = str(int(time.time()))
v1 = hmac.new(
os.environ["Wavly_SECRET"].encode(),
f"{ts}.{body}".encode(),
hashlib.sha256,
).hexdigest()
r = requests.post(
"https://thewavly.com/api/ingest/v1",
data=body,
headers={
"Content-Type": "application/json",
"X-Wavly-Key": os.environ["Wavly_PUBLIC_KEY"],
"X-Wavly-Signature": f"t={ts},v1={v1}",
"Idempotency-Key": "order-9482",
},
)
print(r.json()) # {"ok": True, "accepted": 1}Idempotency
Pass Idempotency-Key: <up-to-128-chars>. A retry with the same key returns the original { ok: true, accepted, duplicate: true } without inserting again. The key TTL is 7 days. Two different bodies under the same key are NOT detected — pick a unique key per logical event.
Errors
| Code | Cause |
|---|---|
| 401 invalid_key | X-Wavly-Key is missing, malformed, or revoked. Check the key in Settings → Sources. |
| 401 invalid_signature | v1= signature didn't match HMAC_SHA256(secret, '<timestamp>.<body>'). Common causes: signing the parsed JSON instead of the raw body; padding the body; or the secret was rotated. |
| 401 stale_timestamp | t= drift exceeded ±300s. Confirm your server clock with `date -u` and that you're using unix seconds (not millis). |
| 403 origin_not_allowed | If you set allowed_origins on the project key, the Origin header must match. Server-to-server callers usually have no Origin header — leave allowed_origins empty for those. |
| 429 rate_limited | 600 events/minute per key by default. Bursts above that get a 429 — slow down or split traffic across keys. |
Rotation
Rotate your secret in Settings → Listening → Project keys → Rotate secret. The previous secret stops verifying signatures the moment rotation completes — there is no overlap window. To minimize downtime, sign with both old and new during the rollout (HMAC callers may include multiple v1= values in the header, and we'll accept any matching one).