Send us a lead with one POST.
One canonical envelope — ours, not a per-partner adapter. Your API key identifies the tenant, so there is no account id in the path to get wrong. Three fields are required. A 202 means the lead is already committed to disk, not queued in someone's memory.
What you are building
One outbound HTTP call. When your system has a lead, POST it as JSON to our endpoint with an API key header. That is the whole integration: no SDK to install, no OAuth dance, no webhook you must host, and no polling.
POSThttps://intake.hellojade.ai/v1/intake
| Header | Value | Why |
|---|---|---|
X-API-Key | the key hellojade issued you | Resolves the tenant. Its registered label becomes the lead's source — you never send one. |
Content-Type | application/json | The body is one JSON object: the envelope below. |
Idempotency-Key | your own stable id for this lead | Makes every retry safe. A repeat returns 200 with the original event_id. |
X-Request-Id | any string up to 64 characters (optional) | Adopted verbatim, echoed in the response header, the error body and our access log. |
A 202 means the event is committed to disk together with its outbox row, in one transaction. It does not mean "queued in memory", and it does not mean the customer's CRM has taken it — delivery to the private actions tier is asynchronous, at-least-once, with exponential backoff and a dead-letter queue that retains the full payload. You mark the lead sent the moment you see the 202.
Machine-readable: the OpenAPI 3 document is the contract — generate your types from it. The integration brief is the same material written for a coding agent to read once, front to back. The agents page has the one-paste prompt.
The smallest request that works
Every header the API cares about, a bounded timeout, and the status, the X-Request-Id and the body printed. Everything else on this page is optional detail.
curl -sS -i -X POST https://intake.hellojade.ai/v1/intake \
-H "X-API-Key: $HELLOJADE_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: acme-leads:A-99812' \
-H 'X-Request-Id: acme-leads/A-99812/1' \
-d '{"first_name":"Dana","last_name":"Whitfield","phone":"6305550142"}'package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
body, _ := json.Marshal(map[string]any{
"first_name": "Dana", "last_name": "Whitfield", "phone": "6305550142",
})
req, _ := http.NewRequest(http.MethodPost, "https://intake.hellojade.ai/v1/intake", bytes.NewReader(body))
req.Header.Set("X-API-Key", os.Getenv("HELLOJADE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "acme-leads:A-99812") // YOUR stable id for the lead
req.Header.Set("X-Request-Id", "acme-leads/A-99812/1")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err) // transport error: safe to retry with the same Idempotency-Key
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, resp.Header.Get("X-Request-Id"), string(out))
// 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back off
}const res = await fetch('https://intake.hellojade.ai/v1/intake', {
method: 'POST',
headers: {
'X-API-Key': process.env.HELLOJADE_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': 'acme-leads:A-99812', // YOUR stable id for the lead
'X-Request-Id': 'acme-leads/A-99812/1',
},
body: JSON.stringify({ first_name: 'Dana', last_name: 'Whitfield', phone: '6305550142' }),
signal: AbortSignal.timeout(20_000),
});
console.log(res.status, res.headers.get('x-request-id'), await res.json());
// 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back offimport os
import requests
r = requests.post(
"https://intake.hellojade.ai/v1/intake",
headers={
"X-API-Key": os.environ["HELLOJADE_API_KEY"],
"Idempotency-Key": "acme-leads:A-99812", # YOUR stable id for the lead
"X-Request-Id": "acme-leads/A-99812/1",
},
json={"first_name": "Dana", "last_name": "Whitfield", "phone": "6305550142"},
timeout=20,
)
print(r.status_code, r.headers.get("X-Request-Id"), r.json())
# 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back off<?php
$ch = curl_init('https://intake.hellojade.ai/v1/intake');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('HELLOJADE_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: acme-leads:A-99812', // YOUR stable id for the lead
'X-Request-Id: acme-leads/A-99812/1',
],
CURLOPT_POSTFIELDS => json_encode([
'first_name' => 'Dana', 'last_name' => 'Whitfield', 'phone' => '6305550142',
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
echo $status, ' ', $body, PHP_EOL;
// 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back offrequire "net/http"
require "json"
uri = URI("https://intake.hellojade.ai/v1/intake")
req = Net::HTTP::Post.new(uri)
req["X-API-Key"] = ENV.fetch("HELLOJADE_API_KEY")
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "acme-leads:A-99812" # YOUR stable id for the lead
req["X-Request-Id"] = "acme-leads/A-99812/1"
req.body = { first_name: "Dana", last_name: "Whitfield", phone: "6305550142" }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 20) { |http| http.request(req) }
puts "#{res.code} #{res["X-Request-Id"]} #{res.body}"
# 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back offimport java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class Quickstart {
public static void main(String[] args) throws Exception {
String body = "{\"first_name\":\"Dana\",\"last_name\":\"Whitfield\",\"phone\":\"6305550142\"}";
HttpRequest req = HttpRequest.newBuilder(URI.create("https://intake.hellojade.ai/v1/intake"))
.timeout(Duration.ofSeconds(20))
.header("X-API-Key", System.getenv("HELLOJADE_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "acme-leads:A-99812") // YOUR stable id for the lead
.header("X-Request-Id", "acme-leads/A-99812/1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " "
+ res.headers().firstValue("x-request-id").orElse("") + " " + res.body());
// 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back off
}
}using System.Net.Http.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
using var req = new HttpRequestMessage(HttpMethod.Post, "https://intake.hellojade.ai/v1/intake");
req.Headers.Add("X-API-Key", Environment.GetEnvironmentVariable("HELLOJADE_API_KEY"));
req.Headers.Add("Idempotency-Key", "acme-leads:A-99812"); // YOUR stable id for the lead
req.Headers.Add("X-Request-Id", "acme-leads/A-99812/1");
req.Content = JsonContent.Create(new { first_name = "Dana", last_name = "Whitfield", phone = "6305550142" });
using var res = await http.SendAsync(req);
var requestId = res.Headers.TryGetValues("X-Request-Id", out var v) ? string.Join(",", v) : "";
Console.WriteLine($"{(int)res.StatusCode} {requestId} {await res.Content.ReadAsStringAsync()}");
// 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back off// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] } serde_json = "1"
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::builder().timeout(Duration::from_secs(20)).build()?;
let res = client
.post("https://intake.hellojade.ai/v1/intake")
.header("X-API-Key", std::env::var("HELLOJADE_API_KEY").expect("HELLOJADE_API_KEY"))
.header("Idempotency-Key", "acme-leads:A-99812") // YOUR stable id for the lead
.header("X-Request-Id", "acme-leads/A-99812/1")
.json(&serde_json::json!({ "first_name": "Dana", "last_name": "Whitfield", "phone": "6305550142" }))
.send()
.await?;
let request_id = res.headers().get("x-request-id")
.and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
println!("{} {} {}", res.status(), request_id, res.text().await?);
// 202 accepted · 200 duplicate · 422 read .fields · 429 wait Retry-After · 5xx back off
Ok(())
}What comes back
{
"event_id": "evt_0198f2c1a4b00000a3d19f4c2b7e",
"status": "accepted",
"received_at": "2026-08-21T14:03:22Z",
"source": "acme-leads",
"flags": []
}Keep the event_id. It is sortable and unguessable, it identifies this lead exactly in any support conversation, and a retry with the same Idempotency-Key returns this same id with "status":"duplicate". Do not parse it — chronological sort order is the only property you may rely on. source is your key's registered label, echoed so you can see what we attributed the lead to.
Prove your key works, without creating a lead
Send your key in the X-API-Key header. The endpoint authenticates before it validates — which gives you a check that costs a customer nothing.
A request that carries your real key and a deliberately empty body gets past authentication and is then rejected by the validator. The response code therefore tells you, unambiguously, whether your key is good — and nothing is stored, delivered, emailed or written to any CRM. Do this before you write any code, and again on the day you go live.
curl -i -X POST https://intake.hellojade.ai/v1/intake \
-H "X-API-Key: $HELLOJADE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{}'| You get | It means | Do |
|---|---|---|
422 validation_failedfields: first_name, last_name, phone → required | Your key is valid and active. The request authenticated, resolved to your tenant, and was correctly rejected for having no fields. Nothing was stored, and no Idempotency-Key was consumed. | Proceed. |
401 unauthorized | The key is missing, mistyped, revoked, or you are pointed at the wrong host. | Check the header name (X-API-Key), check for whitespace or newlines in the value, then ask hellojade whether the key is active. |
429 rate_limited | You are over the per-IP budget. This happens before authentication, so it says nothing about your key. | Wait one second and repeat. |
| connection refused / timeout | You are on http:// (nothing listens on port 80, and it does not redirect), or egress is blocked. | Fix the URL scheme first — that is the usual answer. |
- Storage
- We keep a SHA-256 hash, never the key. The plaintext is shown once at issue and cannot be recovered — a lost key is rotated, not looked up.
- Attribution
- Your key carries a registered label, and that label is the
sourcerecorded on every lead you send. It is not a field in the body and cannot be set, spoofed or omitted. Need a second source? Ask us for a second key. - Rotation
- Ask for a second key, cut over, then tell us to revoke the first. Rotating in place revokes immediately and drops in-flight requests.
- Where it lives
- An environment variable or your secret store — never in source, never in a URL, never in a log line. Never paste it into a ticket, a chat or a shared terminal; if it has been exposed anywhere, say so and ask for a rotation.
Do not "test" with a real-looking lead. Real names and phone numbers reaching a real salesperson's phone is a cost to a real person, and it puts junk in a customer's CRM that someone deletes by hand. If you need a live round trip, ask hellojade for a sandbox key — issued against a tenant with no CRM route and no email route, so a lead posted with it travels the whole pipeline and reaches nobody. Ask before you send test data, not after.
Every field, what we do to it, and why
One JSON object. Three fields are required; send everything else you have and nothing you do not. Lengths are counted in runes, not bytes, so accented and non-Latin names are not penalized; over-cap is a 422 with reason too_long, never a silent truncation.
{
"first_name": "Dana",
"last_name": "Whitfield",
"email": "dana.whitfield@example.com",
"phone": "(630) 555-0142",
"street_address": "418 N Maple St",
"city": "Naperville",
"state": "IL",
"zip": "60540",
"country": "US",
"project_area": "roof",
"project_service": "replacement",
"project_material": "asphalt shingle",
"project_details": "Hail damage on the south slope, insurance claim already filed.",
"external_id": "YOUR-LEAD-ID",
"cost": 555.55
}first_namerequiredThe homeowner's given name.
Normalization: Trimmed; internal runs of whitespace collapse to one space; control characters removed.
e.g.
"Dana"last_namerequiredThe homeowner's surname. Required since 2026-08-24: the destination CRM refuses a contact without one, so it is rejected here — synchronously, with the field named — rather than accepted and lost downstream. If you genuinely do not capture a surname, send a placeholder you can recognize later.
Normalization: Same as
first_name.e.g.
"Whitfield"phonerequiredAny format. A phone that cannot be normalized is not a validation failure — a human can still read it.
Normalization: Reduced to E.164 where possible: 10-digit and 1+10-digit US/CA numbers become
+1XXXXXXXXXX. Anything else is stored exactly as sent and flaggedphone_unnormalized.e.g.
"(630) 555-0142"emailoptionalShape-checked only when present; never checked for deliverability at ingest.
Normalization: Lower-cased. A value that does not look like an address is kept and flagged
email_shape_suspect.e.g.
"dana.whitfield@example.com"street_addressoptionalStreet line of the job address.
e.g.
"418 N Maple St"cityoptionale.g.
"Naperville"stateoptional2-letter US/CA code, or free text for other countries.
Normalization: A 2-letter value is upper-cased; longer values are left alone.
e.g.
"IL"zipoptionalUS 5- or 9-digit, Canadian postal code, or free text elsewhere.
Normalization: A bare US 9-digit ZIP becomes
NNNNN-NNNN; a Canadian code becomesANA NAN.e.g.
"60540"countryoptionalISO-3166 alpha-2. Defaults to
USwhen omitted.Normalization: Upper-cased.
USA/U.S./United States→US;CAN/Canada→CA;MEX/Mexico→MX. Anything else that is not two characters is kept as sent and flaggedcountry_unrecognized.e.g.
"US"project_areaoptionalWhich part of the home. A controlled vocabulary held in a database table, not a code constant — adding an area is an INSERT, not a deploy.
Normalization: An unrecognized value is not a 422: it is stored verbatim, flagged
project_area_unknown, and counted so the vocabulary grows from real traffic.e.g.
"roof"project_serviceoptionalA closed enum of four:
replacement·repair·remodel·maintain.Normalization: Case and surrounding space are ignored. An unrecognized value is stored and flagged
project_service_unknownrather than rejected.e.g.
"replacement"project_materialoptionalFree text for now; a vocabulary can follow once real traffic shows the terms.
e.g.
"asphalt shingle"project_detailsoptionalThe homeowner's own description of the job.
Normalization: Line breaks are preserved here and only here. Runs of three or more blank lines collapse to one.
e.g.
"Hail damage on the south slope, insurance claim already filed."external_idoptionalYour id for this lead. Recorded, never trusted as unique — use
Idempotency-Keyfor deduplication.e.g.
"A-99812"costoptionalOptional. What this contact cost, in US dollars — the amount you are charging us for the lead. Omit it entirely if there is no charge; do not send
0. When the API key is bound to a campaign, the cost is accumulated into that campaign's spend.Normalization: Rounded to cents on receipt. This is the one field that is rejected rather than flagged: a value outside
0.01–999.99is a422with reasonout_of_rangeoncost, because a claim about money lands in a total someone reconciles against an invoice.e.g.
555.55
What we do to your values, in full
Normalization is applied before storage, so the value in the customer's CRM may not be byte-identical to what you sent. The raw body you posted is always kept alongside it, so nothing is lost.
- Leading and trailing whitespace trimmed; runs of internal whitespace collapsed to one space.
- Control characters removed (tabs and newlines become spaces) in every field except
project_details, where line structure is a human's description of the job and is kept. Runs of three or more blank lines there collapse to one. emaillower-cased.countryupper-cased and mapped.phonereduced to E.164 when we can ((630) 555-0142→+16305550142); left exactly as sent, and flagged, when we cannot.- Unmodeled top-level fields are preserved verbatim under
extraon the stored event, and you get anextra_fields_preservedflag back. Send your additional fields at the top level and we collect them for you. extrais a reserved top-level key — do not send one. If you do, it must be a JSON object; any other type makes the whole body fail with400 invalid_json, which is a confusing way to learn this.sourceis not a request field. Your key's label is the source. Asourceyou send anyway lands inextraand is ignored for attribution.
project_area and project_service
project_area is a controlled vocabulary held in a database table on our side, read from the same table the validator reads. An unrecognized value is never rejected — it is stored verbatim, flagged, and counted, so the vocabulary grows from real traffic. Do not map values you are unsure of; sending "roofing" is better than guessing at "roof".
GEThttps://intake.hellojade.ai/v1/vocabulary — unauthenticated, Cache-Control: public, max-age=300. Do not hard-code this list into your source; it grows by database insert, without a deploy on our side. If you need a build-time snapshot, fetch this endpoint in your build.
{
"project_area": [ {"area":"roof","status":"confirmed"}, {"area":"solar","status":"proposed"}, … ],
"project_service": ["replacement", "repair", "remodel", "maintain"],
"required": ["first_name", "last_name", "phone"]
}project_area 37 terms
Listed from the published spec, as it stood when this page was built.
confirmed a term we use deliberately proposed arrived from real traffic, not yet ratified. Both are accepted exactly alike; retired terms are not listed.
project_service
A closed enum of four. Case and surrounding space are ignored. Unlike project_area it does not grow at runtime.
Build a request, test it, send it
A request builder for every field, with the same checks our validator runs — required fields, lengths, the phone, zip, email and cost shapes — and a live JSON preview. Nothing leaves your browser until you press Send.
Request
POST https://intake.hellojade.ai/v1/intake
X-API-Key: ••••••••
Content-Type: application/json
{}Input tests — what the validator would say
Key-check mode: a valid key answers 422 with first_name, last_name and phone → required. Nothing is stored.
Live response
Retries are free — if the key is yours and stable
Send an Idempotency-Key: your own id for the lead. Repeat it and you get 200 with the original event_id and "status":"duplicate". No second lead, no error.
# First call
curl -sS -X POST https://intake.hellojade.ai/v1/intake -H "X-API-Key: $HELLOJADE_API_KEY" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: acme-leads:A-99812' \
-d '{"first_name":"Dana","last_name":"Whitfield","phone":"6305550142"}'
# 202 {"event_id":"evt_0198f2c1…","status":"accepted","received_at":"…","flags":[]}
# Same call again — network blip, your job re-ran, whatever
curl -sS -X POST https://intake.hellojade.ai/v1/intake -H "X-API-Key: $HELLOJADE_API_KEY" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: acme-leads:A-99812' \
-d '{"first_name":"Dana","last_name":"Whitfield","phone":"6305550142"}'
# 200 {"event_id":"evt_0198f2c1…","status":"duplicate","received_at":"…","flags":[]} <- the SAME event_id- What to use
- The id your system already uses for that lead, so a retry of the same lead carries the same key. Not a timestamp, not a UUID generated at send time. A UUIDv4 or ULID you persist against the lead is fine.
- Scope — read this twice
- Dedupe is scoped to the tenant, not to your key. A hellojade customer may have dozens of lead sources posting under dozens of keys, and they all share one namespace. Send a bare
1234and, if another source already sent1234, you get a200pointing at their event and your lead is never stored — silently. Prefix it with something only you use:acme-leads:1234. - Length
- Up to 200 characters.
- A 422 does not consume it
- Send the same key again with a corrected body.
- Without the header
- We fall back to a content hash over the normalized lead within a 24-hour window — good, but your own key is better.
Every status the endpoint produces
A 202 is final. The lead is committed before we answer, so a slow or unavailable system further down the line does not affect it and is not yours to handle.
| Status | error | Meaning | What your code does | Retry? |
|---|---|---|---|---|
202 | — | Accepted and durable. The event and its outbox row are committed to disk, in one transaction, before this answer is sent. | Mark the lead sent. Store event_id. Do not poll, confirm or wait for anything downstream. | — |
200 | — | Duplicate of an Idempotency-Key already accepted for this tenant. The body carries the original event_id and "status":"duplicate". | Mark the lead sent — this was a retry and we already had it. Never treat it as an error. | — |
400 | invalid_json | The body is not a JSON object — or extra was sent and is not an object, or a field carried the wrong JSON type (a stringified number in cost, for example). | Fix the JSON. Log and alert. | never |
401 | unauthorized | Missing, unknown, revoked or inactive X-API-Key. | Check the header name and the value for stray whitespace. This is a configuration problem — alert a human. | never |
405 | method_not_allowed | Anything other than POST. | Fix the method. | never |
413 | body_too_large | Body over the 64 KiB cap. Enforced before authentication, so the body carries no request_id (the header still does). | Trim project_details. Do not retry as-is. | never |
422 | validation_failed | Validation failed. fields lists every failing field at once with a reason — required, too_long, or out_of_range on cost. | Read fields, fix each one, resend. A 422 does not consume the Idempotency-Key. | never as-is |
429 | rate_limited | Over the per-key or the per-IP budget. The per-IP limit is applied before authentication, so its body carries no request_id. | Sleep for Retry-After seconds (currently 1 — a floor, not a strategy), then retry. Do not spend a delivery attempt on it. | after Retry-After |
503 | not_accepting | Our store is unwritable, or the key lookup failed. We answer 503 rather than a 202 we cannot honor — a false 202 loses the lead silently, which is strictly worse than a refusal you will retry. | Retry with exponential backoff. This is us, not you. | yes |
Bodies
{
"event_id": "evt_0198f2c1a4b00000a3d19f4c2b7e",
"status": "accepted",
"received_at": "2026-08-21T14:03:22Z",
"source": "acme-leads",
"flags": []
}{
"error": "validation_failed",
"request_id": "6da674efe8ab8da7",
"fields": { "first_name": "required", "phone": "required", "cost": "out_of_range" }
}status is "accepted" on a 202 and "duplicate" on a 200. flags is always an array, never null. fields maps each failing field to a reason — required, too_long, or out_of_range — and a 422 lists every one, so one round trip tells you everything that is wrong; do not stop at the first.
Two responses are produced before the request reaches the handler and therefore carry no request_id in the body: 413 body_too_large and the per-IP 429 rate_limited. The X-Request-Id header is present on all of them.
Why 503 and not a hopeful 202. If our store cannot commit, you get a 503 and you retry. The alternative — accepting the lead and losing it — looks healthier on every dashboard and costs someone a customer. A 202 here always means the bytes are on disk.
Observations, never rejections
Every one of these comes back on a 202: the lead was accepted. They exist so you can improve what you send without opening a support ticket.
phone_unnormalized- We could not reduce the phone to E.164, so we stored exactly what you sent. The lead is fine and a human can read it — but a normalized number dials from a CRM without editing.
project_area_unknown- The area is not in our vocabulary yet. It is stored verbatim and counted, and recurring terms get added. Nothing was lost.
project_service_unknown- The service is not one of the four we model. Stored verbatim; consider mapping it at your end.
email_shape_suspect- The email does not look like an address. We never check deliverability at ingest, so this is shape only.
extra_fields_preserved- You sent top-level fields we do not model. They are kept verbatim under
extraon the stored event. country_unrecognized- The country is not a 2-letter code we recognize. Stored as sent.
Do not treat a flag as a failure and do not retry on one. Idempotency means you will duplicate nothing, but you will waste your rate limit and hide a real problem behind noise.
Network, rate limits and the retry policy
Nothing exotic: a stock HTTPS client with a 20-second timeout works. The one policy that matters is which statuses you retry.
| Value | Notes | |
|---|---|---|
| Base URL | https://intake.hellojade.ai | Configuration, not a constant in the code. |
| Scheme | HTTPS only | There is no listener on port 80. An http:// URL fails with a connection error, not a redirect — the most common first-day failure. |
| TLS | 1.2 minimum, HTTP/2, public Let's Encrypt certificate | A stock HTTP client works. |
| IP | one IPv4 address, no AAAA record | An IPv6-only egress will not reach us. If your network needs a destination allowlist, ask before pinning an address; it can change. |
| Timeout | use 20 s | Our handler is bounded at 20 s and answers in single-digit milliseconds in practice. |
| Body cap | 64 KiB | Enforced before authentication → 413. |
| Rate — per key | 600 req/min sustained, burst 101 | Yours alone. |
| Rate — per source IP | 120 req/min (2/s), burst 31 | Applied before authentication. Behind a shared NAT egress you share this budget with whoever else is behind it, so a 429 is not always about your volume. |
Retry-After | currently 1 on both limiters | A floor, not a strategy — back off further on repeated 429s. |
The retry policy
- Retry on any
5xx, on a timeout or transport error, and on429afterRetry-After. Use exponential backoff with a cap (30 s) and jitter — without jitter, a fleet of workers recovering from the same outage retries in lockstep and turns a blip into a thundering herd against your own rate limit. - Never retry any other
4xx. A 422 means the body needs fixing; retrying it unchanged just burns your rate limit. A 401 is a configuration problem for a human. - A
429should not consume a delivery attempt; wait and go again. - Because your
Idempotency-Keyis stable, every retry is free and safe — even one where the original actually arrived. - Do not build a queue in front of this. It already is one: we commit durably and handle every downstream failure with retries and a dead-letter queue an operator can replay. A second queue on your side just adds a place for leads to get stuck where nobody is watching.
- Doing a bulk backfill? Ask hellojade first and we will raise the limit for a window. Drip-feeding a backfill to stay under the rate limit takes days and looks like an incident from our side.
X-Request-Id — set it, and you can trace anything
Every response carries an X-Request-Id header. If you send one, we adopt it.
Any string up to 64 characters. It appears in the response header, in the request_id of any error body, and in our access log. Setting it to your own correlation id is the single cheapest thing you can do to make a future support question answerable. You may see the header twice — our edge and our application each set it, to the same value; take the first.
POST /v1/intake HTTP/2
X-API-Key: ••••••••
Content-Type: application/json
Idempotency-Key: acme-leads:8f21c3
X-Request-Id: acme-leads/req/8f21c3
HTTP/2 422
X-Request-Id: acme-leads/req/8f21c3
Content-Type: application/json
{"error":"validation_failed","request_id":"acme-leads/req/8f21c3","fields":{"phone":"required"}}When you contact support
Contact the person at hellojade who issued your key. Include, in this order: the event_id if you got a 202 or 200 (it identifies the lead exactly); the request_id (or the X-Request-Id you set) if you got an error — when there is no event_id, it is the only handle that finds your request in our logs; the HTTP status and response body, verbatim; the UTC timestamp; and your key's label — the source we echo in a 202. Never the key itself.
Each one is the answer to a question you will otherwise get wrong
Read these before writing code. They are the substance of INTEGRATION.md §2, and every reference implementation on this page follows them.
first_name, last_name and phone are required. Nothing else is.
Send everything you have; send nothing you do not. Do not invent placeholder values to satisfy a field —
"email": "none@none.com"is worse than omitting it.Always send Idempotency-Key, and make it your own stable id for the lead.
Not a timestamp, not a UUID generated at send time — the id your system already uses for that lead, so a retry of the same lead carries the same key. We return
200with the originalevent_idfor a repeat, and202for something new. Both are success.Namespace your Idempotency-Key. Dedupe is scoped to the tenant, not to your key.
A single hellojade customer may have dozens of lead sources posting under dozens of keys, and they all share one idempotency namespace. If you send
"1234"and another source already sent"1234", you get a 200 pointing at their event and your lead is never stored. Prefix it with something only you use:acme-leads:1234. A UUIDv4 or ULID that you persist against the lead is equally fine.A 202 means we have it on disk.
The event and its delivery record are committed in one transaction before you get the response. Mark the lead delivered at your end the moment you see it. You do not need to poll, confirm, or wait for anything downstream.
Retry on 5xx, on timeouts, and on 429 after Retry-After. Never retry any other 4xx.
A
422means the body needs fixing; retrying it unchanged just burns your rate limit. Because of rule 2, retries are free and safe.Do not send source.
It is not a field. Your API key's registered label is the source, and we echo it back in the
202. If you need to distinguish two feeds, ask hellojade for a second key rather than adding a field. (Asourceyou send anyway is not rejected — it lands inextraand is ignored for attribution.)Anything we do not model is kept, not rejected.
Unmodeled top-level fields are preserved verbatim under
extraon the stored event, and you get anextra_fields_preservedflag back. If your system has a field that matters, send it and tell hellojade — do not drop it, and do not stuff it intoproject_details.Flags are not errors.
They come back on a successful
202. Do not treat a flag as a failure and do not retry on one — idempotency means you will duplicate nothing, but you will waste your rate limit and hide a real problem behind noise.
Check your own work before you report it complete
Each item is observable — none of them is "it looks right".
- The key check returns 422, not 401, against the key you will ship with.
- A lead with
first_name,last_nameandphonereturns 202. - The same lead sent twice with the same
Idempotency-Keyreturns 202 then 200, with the sameevent_id, and your system records one send, not two. - Your
Idempotency-Keyis namespaced to you and stable across retries — not a bare integer, not a timestamp, not regenerated per attempt. - A lead missing all three required fields returns 422 and your code surfaces all three field names, not just the first.
- A simulated 503 (point at a dead host) retries with growing backoff and does not lose the lead.
- A 422 does not retry.
- A 429 waits and does not consume a delivery attempt.
- The API key does not appear in any log line, error message, stack trace, or committed file. Grep for it.
- A lead whose
project_areayour system cannot map is sent with the raw value rather than dropped or blanked. event_idis stored against your lead record, andrequest_idis logged on every failure, so a support question can be answered.- Your base URL is
https://, and it is configuration, not a constant in the code.
Things that will bite you
http://does not work and does not redirect. Nothing listens on port 80.- Do not treat
200as an error because you expected 202. It means we already had it, which is exactly what you want from a retry. - Do not reuse a bare sequential
Idempotency-Key. Rule 3 — the failure is silent and it loses leads. - Do not parse
event_id. It is opaque; chronological sort order is the only property you may rely on. - Do not poll for status. There is no status to poll. The 202 is the answer.
- Do not hard-code the
project_arealist. Fetch it. - Do not assume a 4xx is our fault or a 5xx is yours. Rule 5 is the whole policy.
- Do not send test leads to production with real-looking names and phone numbers. Use the key check, or ask for a sandbox key.
Adapt this; do not copy it blindly
The same call inside the retry policy: 202 and 200 are done, a 429 waits on Retry-After without spending an attempt, 5xx and transport errors back off with jitter, and any other 4xx stops with the request_id kept for support. Error handling is the part that matters.
#!/usr/bin/env bash
# send-lead.sh <lead.json> <your-lead-id>
# 202/200 → done. 429 → wait Retry-After (does not spend an attempt).
# 5xx / transport → back off and retry. Any other 4xx → stop; keep request_id.
set -euo pipefail
lead_file=$1; lead_id=$2
endpoint='https://intake.hellojade.ai/v1/intake'
max_attempts=5; attempt=1; rate_waits=0
backoff() { echo $(( (1 << ($1 - 1)) < 30 ? (1 << ($1 - 1)) : 30 )); }
while (( attempt <= max_attempts )); do
hdr=$(mktemp)
status=$(curl -sS -o body.json -D "$hdr" -w '%{http_code}' --max-time 20 \
-X POST "$endpoint" \
-H "X-API-Key: $HELLOJADE_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: acme-leads:$lead_id" \
-H "X-Request-Id: acme-leads/$lead_id/$attempt" \
--data-binary "@$lead_file" || echo 000)
case "$status" in
202|200) cat body.json; exit 0 ;; # accepted, or a duplicate we already had
429) rate_waits=$((rate_waits + 1)); (( rate_waits > 10 )) && { echo 'rate limited for too long' >&2; exit 1; }
floor=$(awk 'tolower($1)=="retry-after:"{print $2+0}' "$hdr"); floor=${floor:-1}
b=$(backoff "$rate_waits"); sleep $(( floor > b ? floor : b )) ;; # no attempt spent
000|5*) (( attempt == max_attempts )) && { echo "intake $status" >&2; exit 1; }
sleep "$(backoff "$attempt")"; attempt=$((attempt + 1)) ;;
*) echo "intake rejected the lead ($status, request_id=$(awk 'tolower($1)=="x-request-id:"{print $2; exit}' "$hdr"))" >&2
cat body.json >&2; exit 2 ;; # our bug; do not retry
esac
donepackage intake
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"os"
"strconv"
"time"
)
const endpoint = "https://intake.hellojade.ai/v1/intake"
// SendLead posts one lead and returns its event_id. leadID is YOUR stable id
// for the lead — it becomes the Idempotency-Key, which is what makes every
// retry below safe.
func SendLead(ctx context.Context, client *http.Client, lead map[string]any, leadID string) (string, error) {
body, err := json.Marshal(lead)
if err != nil {
return "", err
}
const maxAttempts = 5
rateWaits := 0
for attempt := 1; attempt <= maxAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("X-API-Key", os.Getenv("HELLOJADE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "acme-leads:"+leadID) // rules 2 + 3: ours, namespaced, stable
req.Header.Set("X-Request-Id", fmt.Sprintf("acme-leads/%s/%d", leadID, attempt))
resp, err := client.Do(req)
if err != nil {
// Transport error or timeout. Safe to retry: the same Idempotency-Key
// cannot create a duplicate even if the request actually arrived.
if attempt == maxAttempts {
return "", err
}
sleep(ctx, backoff(attempt))
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusAccepted || resp.StatusCode == http.StatusOK:
// 202 accepted, 200 duplicate — both mean it is on their disk.
var out map[string]any
if err := json.Unmarshal(respBody, &out); err != nil {
return "", err
}
id, _ := out["event_id"].(string)
return id, nil
case resp.StatusCode == http.StatusTooManyRequests:
// Retry-After is a floor of 1s. Grow our own wait, and do not spend a
// delivery attempt on it.
floor, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
rateWaits++
if rateWaits > 10 {
return "", errors.New("intake: rate limited for too long")
}
sleep(ctx, max(time.Duration(floor)*time.Second, backoff(rateWaits)))
attempt--
case resp.StatusCode >= 500:
if attempt == maxAttempts {
return "", fmt.Errorf("intake %d", resp.StatusCode)
}
sleep(ctx, backoff(attempt))
default:
// Any other 4xx is OUR bug; retrying will not fix it. Keep request_id —
// it is the only handle support can use when there is no event_id.
return "", fmt.Errorf("intake rejected the lead (%d, request_id=%s): %s",
resp.StatusCode, resp.Header.Get("X-Request-Id"), respBody)
}
}
return "", errors.New("unreachable")
}
// backoff is exponential with a 30s cap and up to 500ms of jitter, so a fleet
// of workers recovering from the same outage does not retry in lockstep.
func backoff(n int) time.Duration {
d := min(time.Duration(1<<(n-1))*time.Second, 30*time.Second)
return d + time.Duration(rand.IntN(500))*time.Millisecond
}
func sleep(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}const ENDPOINT = 'https://intake.hellojade.ai/v1/intake';
// Returns the event_id. leadId is YOUR stable id for the lead — it becomes the
// Idempotency-Key, which is what makes every retry below safe.
async function sendLead(lead, leadId) {
const MAX_ATTEMPTS = 5;
let rateLimitWaits = 0;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let res;
try {
res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'X-API-Key': process.env.HELLOJADE_API_KEY,
'Content-Type': 'application/json',
// Rules 2 + 3: OUR id for the lead, namespaced to us, stable across retries.
'Idempotency-Key': 'acme-leads:' + leadId,
// Make support answerable.
'X-Request-Id': 'acme-leads/' + leadId + '/' + attempt,
},
body: JSON.stringify(lead),
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
// Network error or timeout. Safe to retry — the same Idempotency-Key
// means we cannot create a duplicate even if it actually arrived.
if (attempt === MAX_ATTEMPTS) throw err;
await sleep(backoff(attempt));
continue;
}
if (res.status === 202 || res.status === 200) {
const { event_id, flags } = await res.json();
if (flags?.length) console.info('accepted with flags', { leadId, event_id, flags });
return event_id; // done — it is on their disk
}
if (res.status === 429) {
// Retry-After is a floor of 1s. Grow our own wait so a sustained limit
// does not become a hot loop, and do not spend a delivery attempt on it.
const floor = Number(res.headers.get('Retry-After') || 1);
await sleep(Math.max(floor * 1000, backoff(++rateLimitWaits)));
attempt--;
if (rateLimitWaits > 10) throw new Error('intake: rate limited for too long');
continue;
}
if (res.status >= 500) {
if (attempt === MAX_ATTEMPTS) throw new Error('intake ' + res.status);
await sleep(backoff(attempt));
continue;
}
// Any other 4xx is OUR bug. Retrying will not fix it. Keep request_id —
// it is the only handle support can use when there is no event_id.
const body = await res.text();
const reqId = res.headers.get('X-Request-Id');
throw new Error('intake rejected the lead (' + res.status + ', request_id=' + reqId + '): ' + body);
}
}
// Jitter matters: without it a fleet of workers recovering from the same
// outage retries in lockstep and turns a blip into a thundering herd.
const backoff = n => Math.min(30_000, 1000 * 2 ** (n - 1)) + Math.random() * 500;
const sleep = ms => new Promise(r => setTimeout(r, ms));import os
import random
import time
import requests
ENDPOINT = "https://intake.hellojade.ai/v1/intake"
MAX_ATTEMPTS = 5
class IntakeRejected(Exception):
"""A 4xx other than 429: the body needs fixing. Never retried."""
def backoff(n: int) -> float:
# Exponential, 30 s cap, up to 0.5 s of jitter so workers do not retry in lockstep.
return min(30.0, 2.0 ** (n - 1)) + random.random() * 0.5
def send_lead(lead: dict, lead_id: str) -> str:
"""POST one lead; return its event_id. lead_id is YOUR stable id (the Idempotency-Key)."""
rate_waits = 0
attempt = 1
while attempt <= MAX_ATTEMPTS:
headers = {
"X-API-Key": os.environ["HELLOJADE_API_KEY"],
"Idempotency-Key": f"acme-leads:{lead_id}", # rules 2 + 3: ours, namespaced, stable
"X-Request-Id": f"acme-leads/{lead_id}/{attempt}",
}
try:
r = requests.post(ENDPOINT, json=lead, headers=headers, timeout=20)
except requests.RequestException:
# Transport error or timeout: safe to retry, the key cannot duplicate.
if attempt == MAX_ATTEMPTS:
raise
time.sleep(backoff(attempt))
attempt += 1
continue
if r.status_code in (202, 200): # accepted, or a duplicate we already had
data = r.json()
if data.get("flags"):
print("accepted with flags", lead_id, data["event_id"], data["flags"])
return data["event_id"]
if r.status_code == 429:
rate_waits += 1
if rate_waits > 10:
raise RuntimeError("intake: rate limited for too long")
floor = float(r.headers.get("Retry-After", "1"))
time.sleep(max(floor, backoff(rate_waits)))
continue # a 429 does not consume an attempt
if r.status_code >= 500:
if attempt == MAX_ATTEMPTS:
raise RuntimeError(f"intake {r.status_code}")
time.sleep(backoff(attempt))
attempt += 1
continue
# Any other 4xx is our bug; keep request_id for support.
raise IntakeRejected(
f"intake rejected the lead ({r.status_code}, "
f"request_id={r.headers.get('X-Request-Id')}): {r.text}"
)
raise RuntimeError("unreachable")<?php
const ENDPOINT = 'https://intake.hellojade.ai/v1/intake';
const MAX_ATTEMPTS = 5;
// Exponential, 30 s cap, up to 0.5 s of jitter so workers do not retry in lockstep.
function backoff(int $n): float { return min(30.0, 2 ** ($n - 1)) + mt_rand(0, 500) / 1000; }
/** POST one lead; returns its event_id. $leadId is YOUR stable id (the Idempotency-Key). */
function sendLead(array $lead, string $leadId): string
{
$rateWaits = 0;
for ($attempt = 1; $attempt <= MAX_ATTEMPTS; $attempt++) {
$ch = curl_init(ENDPOINT);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('HELLOJADE_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: acme-leads:' . $leadId, // rules 2 + 3: ours, namespaced, stable
'X-Request-Id: acme-leads/' . $leadId . '/' . $attempt,
],
CURLOPT_POSTFIELDS => json_encode($lead),
]);
$raw = curl_exec($ch);
if ($raw === false) { // transport error or timeout: safe to retry
curl_close($ch);
if ($attempt === MAX_ATTEMPTS) throw new RuntimeException('intake unreachable');
usleep((int) (backoff($attempt) * 1e6));
continue;
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerLen = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$headers = substr($raw, 0, $headerLen);
$body = substr($raw, $headerLen);
if ($status === 202 || $status === 200) { // accepted, or a duplicate we already had
$data = json_decode($body, true);
if (!empty($data['flags'])) error_log('accepted with flags: ' . implode(',', $data['flags']));
return $data['event_id'];
}
if ($status === 429) {
if (++$rateWaits > 10) throw new RuntimeException('intake: rate limited for too long');
$floor = preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $m) ? (int) $m[1] : 1;
usleep((int) (max($floor, backoff($rateWaits)) * 1e6));
$attempt--; // a 429 does not consume an attempt
continue;
}
if ($status >= 500) {
if ($attempt === MAX_ATTEMPTS) throw new RuntimeException("intake $status");
usleep((int) (backoff($attempt) * 1e6));
continue;
}
// Any other 4xx is our bug; keep request_id for support.
preg_match('/^X-Request-Id:\s*(\S+)/mi', $headers, $m);
throw new InvalidArgumentException(
"intake rejected the lead ($status, request_id=" . ($m[1] ?? '') . "): $body");
}
throw new RuntimeException('unreachable');
}require "net/http"
require "json"
ENDPOINT = URI("https://intake.hellojade.ai/v1/intake")
MAX_ATTEMPTS = 5
class IntakeRejected < StandardError; end # a 4xx other than 429: never retried
# Exponential, 30 s cap, up to 0.5 s of jitter so workers do not retry in lockstep.
def backoff(n) = [30.0, 2.0**(n - 1)].min + rand * 0.5
# POST one lead; returns its event_id. lead_id is YOUR stable id (the Idempotency-Key).
def send_lead(lead, lead_id)
rate_waits = 0
attempt = 1
while attempt <= MAX_ATTEMPTS
req = Net::HTTP::Post.new(ENDPOINT)
req["X-API-Key"] = ENV.fetch("HELLOJADE_API_KEY")
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "acme-leads:#{lead_id}" # rules 2 + 3: ours, namespaced, stable
req["X-Request-Id"] = "acme-leads/#{lead_id}/#{attempt}"
req.body = lead.to_json
begin
res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true,
open_timeout: 20, read_timeout: 20) { |http| http.request(req) }
rescue SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout
raise if attempt == MAX_ATTEMPTS # transport error or timeout: safe to retry
sleep backoff(attempt)
attempt += 1
next
end
case res.code.to_i
when 202, 200 # accepted, or a duplicate we already had
data = JSON.parse(res.body)
warn "accepted with flags #{data['flags']}" if data["flags"]&.any?
return data["event_id"]
when 429
rate_waits += 1
raise "intake: rate limited for too long" if rate_waits > 10
sleep [res["Retry-After"].to_f, backoff(rate_waits)].max
next # a 429 does not consume an attempt
when 500..599
raise "intake #{res.code}" if attempt == MAX_ATTEMPTS
sleep backoff(attempt)
attempt += 1
else
# Any other 4xx is our bug; keep request_id for support.
raise IntakeRejected, "intake rejected the lead (#{res.code}, request_id=#{res['X-Request-Id']}): #{res.body}"
end
end
raise "unreachable"
endimport java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class Intake {
private static final String ENDPOINT = "https://intake.hellojade.ai/v1/intake";
private static final int MAX_ATTEMPTS = 5;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
/** A 4xx other than 429: the body needs fixing. Never retried. */
public static final class Rejected extends RuntimeException { Rejected(String m) { super(m); } }
/** POST one lead (serialized JSON); returns its event_id. leadId is YOUR stable id (the Idempotency-Key). */
public String sendLead(String leadJson, String leadId) throws IOException, InterruptedException {
int rateWaits = 0;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
HttpRequest req = HttpRequest.newBuilder(URI.create(ENDPOINT))
.timeout(Duration.ofSeconds(20))
.header("X-API-Key", System.getenv("HELLOJADE_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "acme-leads:" + leadId) // rules 2 + 3: ours, namespaced, stable
.header("X-Request-Id", "acme-leads/" + leadId + "/" + attempt)
.POST(HttpRequest.BodyPublishers.ofString(leadJson))
.build();
HttpResponse<String> res;
try {
res = client.send(req, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) { // transport error or timeout: safe to retry
if (attempt == MAX_ATTEMPTS) throw e;
Thread.sleep(backoffMs(attempt));
continue;
}
int status = res.statusCode();
if (status == 202 || status == 200) { // accepted, or a duplicate we already had
return eventId(res.body());
}
if (status == 429) {
if (++rateWaits > 10) throw new IOException("intake: rate limited for too long");
long floor = Long.parseLong(res.headers().firstValue("Retry-After").orElse("1")) * 1000;
Thread.sleep(Math.max(floor, backoffMs(rateWaits)));
attempt--; // a 429 does not consume an attempt
continue;
}
if (status >= 500) {
if (attempt == MAX_ATTEMPTS) throw new IOException("intake " + status);
Thread.sleep(backoffMs(attempt));
continue;
}
// Any other 4xx is our bug; keep request_id for support.
throw new Rejected("intake rejected the lead (" + status + ", request_id="
+ res.headers().firstValue("X-Request-Id").orElse("") + "): " + res.body());
}
throw new IllegalStateException("unreachable");
}
// Exponential, 30 s cap, up to 500 ms of jitter so workers do not retry in lockstep.
private static long backoffMs(int n) {
return Math.min(30_000L, 1000L << (n - 1)) + ThreadLocalRandom.current().nextLong(500);
}
// Use your JSON library here; this keeps the sample dependency-free.
private static String eventId(String body) {
Matcher m = Pattern.compile("\"event_id\"\\s*:\\s*\"([^\"]+)\"").matcher(body);
return m.find() ? m.group(1) : "";
}
}using System.Net.Http.Json;
using System.Text.Json;
public sealed class IntakeClient
{
private const string Endpoint = "https://intake.hellojade.ai/v1/intake";
private const int MaxAttempts = 5;
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
/// <summary>A 4xx other than 429: the body needs fixing. Never retried.</summary>
public sealed class RejectedException(string message) : Exception(message);
/// <summary>POST one lead; returns its event_id. leadId is YOUR stable id (the Idempotency-Key).</summary>
public static async Task<string> SendLeadAsync(object lead, string leadId, CancellationToken ct = default)
{
var rateWaits = 0;
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
using var req = new HttpRequestMessage(HttpMethod.Post, Endpoint);
req.Headers.Add("X-API-Key", Environment.GetEnvironmentVariable("HELLOJADE_API_KEY"));
req.Headers.Add("Idempotency-Key", $"acme-leads:{leadId}"); // rules 2 + 3: ours, namespaced, stable
req.Headers.Add("X-Request-Id", $"acme-leads/{leadId}/{attempt}");
req.Content = JsonContent.Create(lead); // Content-Type: application/json
HttpResponseMessage res;
try
{
res = await Http.SendAsync(req, ct);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
// Transport error or timeout: safe to retry, the same key cannot duplicate.
if (attempt == MaxAttempts) throw;
await Task.Delay(Backoff(attempt), ct);
continue;
}
using (res)
{
var status = (int)res.StatusCode;
var body = await res.Content.ReadAsStringAsync(ct);
if (status is 202 or 200) // accepted, or a duplicate we already had
{
using var doc = JsonDocument.Parse(body);
return doc.RootElement.GetProperty("event_id").GetString()!;
}
if (status == 429)
{
if (++rateWaits > 10) throw new HttpRequestException("intake: rate limited for too long");
var floor = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
var wait = Backoff(rateWaits);
await Task.Delay(floor > wait ? floor : wait, ct);
attempt--; // a 429 does not consume an attempt
continue;
}
if (status >= 500)
{
if (attempt == MaxAttempts) throw new HttpRequestException($"intake {status}");
await Task.Delay(Backoff(attempt), ct);
continue;
}
// Any other 4xx is our bug; keep request_id for support.
var requestId = res.Headers.TryGetValues("X-Request-Id", out var v) ? string.Join(",", v) : "";
throw new RejectedException($"intake rejected the lead ({status}, request_id={requestId}): {body}");
}
}
throw new InvalidOperationException("unreachable");
}
// Exponential, 30 s cap, up to 500 ms of jitter so workers do not retry in lockstep.
private static TimeSpan Backoff(int n) =>
TimeSpan.FromMilliseconds(Math.Min(30_000, 1000 * Math.Pow(2, n - 1)) + Random.Shared.Next(500));
}// Cargo.toml:
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
// rand = "0.8"
use std::time::Duration;
const ENDPOINT: &str = "https://intake.hellojade.ai/v1/intake";
const MAX_ATTEMPTS: u32 = 5;
#[derive(Debug)]
pub enum IntakeError {
Transport(reqwest::Error),
/// A 4xx other than 429: the body needs fixing. Never retried. Keep request_id for support.
Rejected { status: u16, request_id: String, body: String },
Exhausted(String),
}
// Exponential, 30 s cap, up to 500 ms of jitter so workers do not retry in lockstep.
fn backoff(n: u32) -> Duration {
let base = (1000u64 << (n - 1)).min(30_000);
Duration::from_millis(base + rand::random::<u64>() % 500)
}
/// POST one lead; returns its event_id. lead_id is YOUR stable id (the Idempotency-Key).
pub async fn send_lead(client: &reqwest::Client, lead: &serde_json::Value, lead_id: &str)
-> Result<String, IntakeError>
{
let key = std::env::var("HELLOJADE_API_KEY").expect("HELLOJADE_API_KEY");
let mut rate_waits = 0u32;
let mut attempt = 1u32;
while attempt <= MAX_ATTEMPTS {
let sent = client
.post(ENDPOINT)
.timeout(Duration::from_secs(20))
.header("X-API-Key", &key)
.header("Idempotency-Key", format!("acme-leads:{lead_id}")) // rules 2 + 3: ours, namespaced, stable
.header("X-Request-Id", format!("acme-leads/{lead_id}/{attempt}"))
.json(lead) // Content-Type: application/json
.send()
.await;
let res = match sent {
Ok(r) => r,
Err(e) => { // transport error or timeout: safe to retry
if attempt == MAX_ATTEMPTS { return Err(IntakeError::Transport(e)); }
tokio::time::sleep(backoff(attempt)).await;
attempt += 1;
continue;
}
};
let status = res.status().as_u16();
let request_id = res.headers().get("x-request-id")
.and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
let retry_after = res.headers().get("retry-after")
.and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok()).unwrap_or(1);
let body = res.text().await.map_err(IntakeError::Transport)?;
match status {
202 | 200 => { // accepted, or a duplicate we already had
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| IntakeError::Exhausted(e.to_string()))?;
return Ok(v["event_id"].as_str().unwrap_or("").to_string());
}
429 => {
rate_waits += 1;
if rate_waits > 10 { return Err(IntakeError::Exhausted("rate limited for too long".into())); }
tokio::time::sleep(Duration::from_secs(retry_after).max(backoff(rate_waits))).await;
// a 429 does not consume an attempt
}
500..=599 => {
if attempt == MAX_ATTEMPTS { return Err(IntakeError::Exhausted(format!("intake {status}"))); }
tokio::time::sleep(backoff(attempt)).await;
attempt += 1;
}
_ => return Err(IntakeError::Rejected { status, request_id, body }),
}
}
Err(IntakeError::Exhausted("unreachable".into()))
}Client libraries, one per language
Every client does what the reference implementation above does — the headers, the idempotency key, the retry policy — so you do not have to. They are named leads-<language> in the hellojade-ai organization on GitHub.
- leads-goGo
Go client for the Partner Intake API. Module path github.com/hellojade-ai/leads-go.
- leads-cliGo
Command-line client: check a key, post a lead, read the vocabulary.
- leads-rustRust
Rust client for the Partner Intake API.
- leads-nodeJavaScript
Node.js / TypeScript client for the Partner Intake API.
- leads-jsJavaScript
Browser and edge-runtime JavaScript client.
- leads-pythonPython
Python client for the Partner Intake API.
- leads-rubyRuby
Ruby client for the Partner Intake API.
- leads-phpPHP
PHP client for the Partner Intake API.
- leads-javaJava
Java client for the Partner Intake API.
- leads-dotnetC#
.NET (C#) client for the Partner Intake API.
No SDK is required. The API is one HTTPS POST, and the reference implementation above is complete. Use a client when it saves you the retry loop, not because you must.