Developers · Partner Intake API · v1

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.

Overview

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

HeaderValueWhy
X-API-Keythe key hellojade issued youResolves the tenant. Its registered label becomes the lead's source — you never send one.
Content-Typeapplication/jsonThe body is one JSON object: the envelope below.
Idempotency-Keyyour own stable id for this leadMakes every retry safe. A repeat returns 200 with the original event_id.
X-Request-Idany 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.

Quickstart

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
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"}'
Go
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
}
Node
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 off
Python
import 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
<?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 off
Ruby
require "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 off
Java
import 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
    }
}
C#
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
Rust
// 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

202 Accepted
{
  "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.

Authentication

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 — the key check
curl -i -X POST https://intake.hellojade.ai/v1/intake \
  -H "X-API-Key: $HELLOJADE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{}'
You getIt meansDo
422 validation_failed
fields: 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 unauthorizedThe 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_limitedYou are over the per-IP budget. This happens before authentication, so it says nothing about your key.Wait one second and repeat.
connection refused / timeoutYou 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 source recorded 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.

The envelope

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.

The full envelope
{
  "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_namerequiredstring · ≤ 100 runes

The homeowner's given name.

Normalization: Trimmed; internal runs of whitespace collapse to one space; control characters removed.

e.g. "Dana"

last_namerequiredstring · ≤ 100 runes

The 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"

phonerequiredstring · ≤ 40 runes

Any 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 flagged phone_unnormalized.

e.g. "(630) 555-0142"

emailoptionalstring · ≤ 254 runes

Shape-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_addressoptionalstring · ≤ 200 runes

Street line of the job address.

e.g. "418 N Maple St"

cityoptionalstring · ≤ 100 runes

e.g. "Naperville"

stateoptionalstring · ≤ 100 runes

2-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"

zipoptionalstring · ≤ 20 runes

US 5- or 9-digit, Canadian postal code, or free text elsewhere.

Normalization: A bare US 9-digit ZIP becomes NNNNN-NNNN; a Canadian code becomes ANA NAN.

e.g. "60540"

countryoptionalstring · ≤ 60 runes

ISO-3166 alpha-2. Defaults to US when omitted.

Normalization: Upper-cased. USA / U.S. / United StatesUS; CAN / CanadaCA; MEX / MexicoMX. Anything else that is not two characters is kept as sent and flagged country_unrecognized.

e.g. "US"

project_areaoptionalstring · ≤ 100 runes

Which 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_serviceoptionalstring

A closed enum of four: replacement · repair · remodel · maintain.

Normalization: Case and surrounding space are ignored. An unrecognized value is stored and flagged project_service_unknown rather than rejected.

e.g. "replacement"

project_materialoptionalstring · ≤ 100 runes

Free text for now; a vocabulary can follow once real traffic shows the terms.

e.g. "asphalt shingle"

project_detailsoptionalstring · ≤ 4000 runes

The 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_idoptionalstring · ≤ 200 runes

Your id for this lead. Recorded, never trusted as unique — use Idempotency-Key for deduplication.

e.g. "A-99812"

costoptionalnumber · 0.01 – 999.99

Optional. 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.01999.99 is a 422 with reason out_of_range on cost, 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.
  • email lower-cased. country upper-cased and mapped. phone reduced 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 extra on the stored event, and you get an extra_fields_preserved flag back. Send your additional fields at the top level and we collect them for you.
  • extra is 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 with 400 invalid_json, which is a confusing way to learn this.
  • source is not a request field. Your key's label is the source. A source you send anyway lands in extra and is ignored for attribution.
Vocabulary

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.

GET /v1/vocabulary
{
  "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.

Playground

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.

Mode

The key check is the default and the only thing you should send against a production key. A 422 listing the three required fields means the key is valid — nothing was stored.

Headers
Kept in this tab's sessionStorage only — never stored on our servers, never sent anywhere but intake.hellojade.ai, gone when the tab closes.
Your own stable id, namespaced to you. Ignored in key-check mode.
Optional. Echoed back on every response.
Body (full-lead mode)
A JSON object of unmodeled top-level fields. They are preserved under extra and flagged extra_fields_preserved.
Presets:

Send posts to https://intake.hellojade.ai/v1/intake from this browser tab with the key above. It needs JavaScript and it needs the intake host to allow this origin (CORS); if the browser blocks it, the result panel says so and the curl above does the same thing from a terminal.

Request

What will be sent
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 phonerequired. Nothing is stored.

Live response

Nothing sent yet.

This creates a real lead

You are about to POST the full body with your key. It will be committed, delivered to the tenant's CRM and emailed to real people — exactly like a production lead. If this is a production key, stop and use the key check or ask hellojade for a sandbox key.

Type send to confirm.

Idempotency

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.

curl — the same lead twice
# 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 1234 and, if another source already sent 1234, you get a 200 pointing 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.
Responses

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.

HTTP status codes returned by POST /v1/intake, the error code in the body, and what your code does
StatuserrorMeaningWhat your code doesRetry?
202Accepted 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.
200Duplicate 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.
400invalid_jsonThe 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
401unauthorizedMissing, 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
405method_not_allowedAnything other than POST.Fix the method.never
413body_too_largeBody 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
422validation_failedValidation 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
429rate_limitedOver 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
503not_acceptingOur 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

Success — 202 (or 200 with status "duplicate")
{
  "event_id": "evt_0198f2c1a4b00000a3d19f4c2b7e",
  "status": "accepted",
  "received_at": "2026-08-21T14:03:22Z",
  "source": "acme-leads",
  "flags": []
}
Error — 422 lists EVERY failing field at once
{
  "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.

Flags

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 extra on 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.

Limits & retries

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.

ValueNotes
Base URLhttps://intake.hellojade.aiConfiguration, not a constant in the code.
SchemeHTTPS onlyThere is no listener on port 80. An http:// URL fails with a connection error, not a redirect — the most common first-day failure.
TLS1.2 minimum, HTTP/2, public Let's Encrypt certificateA stock HTTP client works.
IPone IPv4 address, no AAAA recordAn IPv6-only egress will not reach us. If your network needs a destination allowlist, ask before pinning an address; it can change.
Timeoutuse 20 sOur handler is bounded at 20 s and answers in single-digit milliseconds in practice.
Body cap64 KiBEnforced before authentication → 413.
Rate — per key600 req/min sustained, burst 101Yours alone.
Rate — per source IP120 req/min (2/s), burst 31Applied 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-Aftercurrently 1 on both limitersA floor, not a strategy — back off further on repeated 429s.

The retry policy

  • Retry on any 5xx, on a timeout or transport error, and on 429 after Retry-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 429 should not consume a delivery attempt; wait and go again.
  • Because your Idempotency-Key is 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.
Tracing

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.

Request and response
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.

The eight rules

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.

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

  2. 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 200 with the original event_id for a repeat, and 202 for something new. Both are success.

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

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

  5. Retry on 5xx, on timeouts, and on 429 after Retry-After. Never retry any other 4xx.

    A 422 means the body needs fixing; retrying it unchanged just burns your rate limit. Because of rule 2, retries are free and safe.

  6. 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. (A source you send anyway is not rejected — it lands in extra and is ignored for attribution.)

  7. Anything we do not model is kept, not rejected.

    Unmodeled top-level fields are preserved verbatim under extra on the stored event, and you get an extra_fields_preserved flag back. If your system has a field that matters, send it and tell hellojade — do not drop it, and do not stuff it into project_details.

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

Definition of done

Check your own work before you report it complete

Each item is observable — none of them is "it looks right".

  1. The key check returns 422, not 401, against the key you will ship with.
  2. A lead with first_name, last_name and phone returns 202.
  3. The same lead sent twice with the same Idempotency-Key returns 202 then 200, with the same event_id, and your system records one send, not two.
  4. Your Idempotency-Key is namespaced to you and stable across retries — not a bare integer, not a timestamp, not regenerated per attempt.
  5. A lead missing all three required fields returns 422 and your code surfaces all three field names, not just the first.
  6. A simulated 503 (point at a dead host) retries with growing backoff and does not lose the lead.
  7. A 422 does not retry.
  8. A 429 waits and does not consume a delivery attempt.
  9. The API key does not appear in any log line, error message, stack trace, or committed file. Grep for it.
  10. A lead whose project_area your system cannot map is sent with the raw value rather than dropped or blanked.
  11. event_id is stored against your lead record, and request_id is logged on every failure, so a support question can be answered.
  12. 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 200 as 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_area list. 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.
Reference implementation

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.

curl (shell)
#!/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
done
Go
package 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):
	}
}
Node
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));
Python
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
<?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');
}
Ruby
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"
end
Java
import 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) : "";
    }
}
C#
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));
}
Rust
// 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()))
}
SDKs

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.

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.

Ready when you are

Need a partner key?

Tell us which hellojade customer you send leads to and what your feed is called. A sandbox key for testing is available on request.

About your business
Pick a time (Central)
Where do we send the invite?

No card, no commitment. We'll confirm by email.

Rather look around first? Contact us or All developer resources.

CallFree AI-visibility audit