Webhook Delivery
The webhook contract for real-time X/Twitter tweet delivery: payload shape, headers, HMAC-SHA256 signature checks in Node, Python, Go and PHP, plus retries.
Everything your endpoint receives, and what it has to do in return.
Payload
Every delivery is a POST with a JSON body:
{
"type": "tweet",
"delivery_id": "3f1c8a2e-4b7d-4c19-9e5a-8d2f6b1c0a34",
"tweet": {
"id": "1815432109876543210",
"text": "Shipping something today.",
"createdAt": "2026-07-26T09:14:22.000Z",
"isReply": false,
"isQuote": false,
"author": {
"id": "44196397",
"userName": "elonmusk",
"name": "Elon Musk",
"isVerified": true
}
}
}tweet is the same normalized tweet object returned by the read endpoints, so whatever parses Tweet Detail already parses this.
type is "tweet" for real deliveries and "test" for payloads sent by Test webhook.
Headers
X-GetXAPI-Signature: t=1785057262,v1=4a7f2e...
X-GetXAPI-Delivery-Id: 3f1c8a2e-4b7d-4c19-9e5a-8d2f6b1c0a34
Content-Type: application/json
User-Agent: GetXAPI-Webhook/1.0| Header | Meaning |
|---|---|
X-GetXAPI-Signature | t = Unix timestamp when signed, v1 = HMAC-SHA256 hex digest |
X-GetXAPI-Delivery-Id | Stable ID for this delivery. Also present inside the body. |
Verifying the signature
This is not optional. Your webhook URL is reachable by anyone who guesses it. The signature is the only thing that proves a request came from GetXAPI and wasn't modified in transit. Reject anything that fails verification.
Why a signature and not a shared secret
A common shortcut is to put a static token in a header or query string and compare it. That proves the caller knows a secret, but nothing else: it does not prove the body is unmodified, and the token is replayable forever by anyone who captures one request.
An HMAC signature covers the payload itself, so tampering invalidates it, and it is bound to a timestamp, so a captured request expires. That is why the check below verifies three things rather than one: the digest, the age, and the comparison method.
The signature is an HMAC-SHA256 over the string `${t}.${raw_body}` keyed with your webhook's signing_secret.
Three rules that catch most integration bugs:
- Use the raw request body, exactly as received, before any JSON parsing. Parsing and re-serializing changes whitespace and key order, which changes the bytes, which breaks the signature.
- Check the timestamp. Reject anything older than about 5 minutes so a captured request can't be replayed later.
- Compare in constant time. Use your language's timing-safe comparison, not
==.
delivery_id is inside the signed body, so an attacker can't swap it to bypass your deduplication.
Node.js (Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
// raw body is required, do NOT use express.json() on this route
app.post("/hooks/getxapi", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("X-GetXAPI-Signature"), process.env.GETXAPI_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const { tweet, delivery_id } = JSON.parse(rawBody);
res.sendStatus(200); // ack FIRST
enqueue(delivery_id, tweet); // then do the work
});
function verify(rawBody, header, secret) {
const m = /t=(\d+),v1=([a-f0-9]+)/.exec(header || "");
if (!m) return false;
const [, t, sig] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5-minute window
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(sig, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python (Flask)
import hmac, hashlib, os, re, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["GETXAPI_WEBHOOK_SECRET"]
@app.post("/hooks/getxapi")
def handle():
raw = request.get_data() # bytes, unparsed
if not verify(raw, request.headers.get("X-GetXAPI-Signature", "")):
abort(401)
payload = request.get_json()
enqueue(payload["delivery_id"], payload["tweet"])
return "", 200
def verify(raw: bytes, header: str) -> bool:
m = re.match(r"t=(\d+),v1=([a-f0-9]+)", header)
if not m:
return False
t, sig = m.group(1), m.group(2)
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256
).hexdigest()
return hmac.compare_digest(sig, expected)Go
func verify(rawBody []byte, header, secret string) bool {
m := regexp.MustCompile(`t=(\d+),v1=([a-f0-9]+)`).FindStringSubmatch(header)
if m == nil {
return false
}
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(m[2]), []byte(expected))
}PHP
function verify(string $rawBody, string $header, string $secret): bool {
if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) return false;
[, $t, $sig] = $m;
if (abs(time() - (int)$t) > 300) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $sig);
}
// $rawBody = file_get_contents('php://input');Requirements for your endpoint
| Requirement | Why |
|---|---|
| HTTPS on a public address | Plain HTTP and private/internal addresses are rejected when the webhook is created |
| Respond within 10 seconds | Slower than that counts as a failure and triggers a retry |
Return 2xx immediately | Acknowledge first, process asynchronously. Doing work inline is the most common cause of timeouts |
| Be idempotent | Delivery is at-least-once. Dedupe on delivery_id |
The single biggest mistake is doing real work (a database write, an LLM call, a downstream API request) before responding. Push it onto a queue, return 200, and process it out of band. A busy account can deliver faster than your handler runs.
Retries
We retry only when a failure looks temporary:
| Response | Retried? |
|---|---|
2xx | No, this is success |
408, 429, any 5xx | Yes |
| Network error, DNS failure, timeout | Yes |
Any other 4xx (400, 401, 404, 410, …) | No, dropped immediately |
A 404 or 410 means the endpoint is wrong, and repeating the request won't fix it. Those are dead-lettered right away so a stale URL doesn't generate traffic forever.
Backoff is deliberate on both sides. Retrying immediately and at a fixed interval is what turns one brief outage into a thundering herd, where every pending delivery for every customer hits your endpoint at the same instant the moment it recovers. Exponential spacing plus jitter spreads that reconnect out so a recovering server is not knocked over by its own backlog.
Retries use exponential backoff starting at 10 seconds, doubling each time, with ±20% jitter, up to 8 attempts:
10s → 20s → 40s → 80s → 2.7m → 5.3m → 10.7mThat's roughly 21 minutes of retrying. After the last attempt the delivery is dead-lettered and appears in failed_deliveries on Health.
Testing your integration
Before pointing a monitor at it, use Test webhook. It sends a real, correctly signed request through the same delivery path as a live tweet and reports back the HTTP status your server returned, so you can confirm signature verification works before real traffic depends on it.
{ "type": "test", "message": "GetXAPI webhook test", "timestamp": "2026-07-26T09:14:22.000Z", "delivery_id": "…" }Note that a test payload has no tweet field. Branch on type if your handler assumes one is always present.
Frequently asked questions
How many times is a webhook retried?
Up to 8 attempts, spaced by exponential backoff with jitter, which is roughly 21 minutes end to end. After the final attempt the delivery is dead-lettered.
Which HTTP status codes trigger a retry?
408, 429, and any 5xx, plus network-level failures such as a timeout or DNS error. Every other 4xx is treated as permanent and dropped immediately, because a 400, 401, 404 or 410 will not fix itself on the next attempt.
Why do retries need jitter?
Without it every queued delivery retries on the same schedule and arrives simultaneously, so a recovering endpoint is hit by its entire backlog at once. The ±20% jitter spreads those attempts apart.
Why did I receive the same tweet twice?
Delivery is at-least-once. If your endpoint accepts a delivery but the acknowledgement is lost in transit, we retry it, because from our side an unacknowledged delivery is indistinguishable from a failed one. Dedupe on delivery_id, which is inside the signed body and therefore cannot be tampered with.
My signature check keeps failing. What is wrong?
Almost always the raw body. If any middleware parses the JSON before you verify, then re-serializes it, the bytes change and the digest will never match. Capture the raw bytes on that route specifically, as the examples above do.
How long should I allow before rejecting an old timestamp?
About 5 minutes. Long enough to tolerate clock skew between your server and ours, short enough that a captured request cannot be replayed later.
Overview
Get X/Twitter tweets pushed to your server in real time. Register the accounts to monitor and receive every new tweet as an HMAC-signed webhook within seconds.
Create WebhookNew
Register an HTTPS destination for real-time X/Twitter tweet delivery and receive its HMAC signing secret, which is shown once and never returned again.