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.

HMAC-SHA256 Idempotent ±300s skew

Quick start

  1. Create a project key at Settings → Listening and copy the bsk_… secret on first reveal (you only see it once).
  2. Send POST /api/ingest/v1 with the headers below and a JSON body of { visitor_id, events: [...] }.
  3. Verify the response: { ok: true, accepted: <int> }. Retry on 5xx.

Headers

NameRequiredDescription
X-Wavly-KeyyesYour public key (bpk_pub_…). Lets us look up your project's secret without sending it over the wire.
X-Wavly-Signatureyest=<unix_seconds>,v1=<hmac_sha256_hex>. Compute v1 = HMAC_SHA256(secret, '<timestamp>.<raw_body>'). Same recipe Stripe uses.
Idempotency-KeynoOptional. Up to 128 chars. Retrying with the same key returns the same accepted count without re-inserting. TTL: 7 days.
Content-Typeyesapplication/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

CodeCause
401 invalid_keyX-Wavly-Key is missing, malformed, or revoked. Check the key in Settings → Sources.
401 invalid_signaturev1= 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_timestampt= drift exceeded ±300s. Confirm your server clock with `date -u` and that you're using unix seconds (not millis).
403 origin_not_allowedIf 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_limited600 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).