GetXAPI
Monitoring

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
HeaderMeaning
X-GetXAPI-Signaturet = Unix timestamp when signed, v1 = HMAC-SHA256 hex digest
X-GetXAPI-Delivery-IdStable 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:

  1. 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.
  2. Check the timestamp. Reject anything older than about 5 minutes so a captured request can't be replayed later.
  3. 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

RequirementWhy
HTTPS on a public addressPlain HTTP and private/internal addresses are rejected when the webhook is created
Respond within 10 secondsSlower than that counts as a failure and triggers a retry
Return 2xx immediatelyAcknowledge first, process asynchronously. Doing work inline is the most common cause of timeouts
Be idempotentDelivery 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:

ResponseRetried?
2xxNo, this is success
408, 429, any 5xxYes
Network error, DNS failure, timeoutYes
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.7m

That'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.

On this page