> ## Documentation Index
> Fetch the complete documentation index at: https://wordsmithai.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# HTTPS receiver

> Receive Wordsmith audit events on your own HTTPS endpoint and verify that Wordsmith sent them.

> [!NOTE]
>
> **Preview.** To discuss access to audit streaming for your organisation, contact your Wordsmith account manager.

An **HTTPS** destination posts batches of audit events to a URL you run. Use it for a collector that Wordsmith has no package for, or to forward events into your own pipeline. If your tool can poll instead, [List Audit Events](https://wordsmithai.mintlify.site/api-reference/audit/list-audit-events.md) needs no public endpoint.

## What Wordsmith sends

Each request is a `POST` of one batch, with up to 500 events and 1 MB. The body depends on the destination's format:

* **JSON**: a JSON array of events (`Content-Type: application/json`). Each event has the fields described in [List Audit Events](https://wordsmithai.mintlify.site/api-reference/audit/list-audit-events.md#response).
* **CEF**: one CEF line per event, separated by newlines (`Content-Type: text/plain; charset=utf-8`). The event ID is in the `externalId` extension.

| Header | Value |
| - | - |
| `Wordsmith-Signature` | `t=<unix seconds>,v1=<signature>`. While a rotated secret is still valid, a second `v1=` is signed with the previous secret. |
| `Wordsmith-Signature-Timestamp` | The same `t`. |
| `Wordsmith-Signature-Key-Id` | The ID of the current signing secret. |
| `Wordsmith-Event-Id` | The batch ID. A retry of the same batch carries the same ID. |
| `Authorization` | `Bearer <token>`, if you gave the destination a bearer token. |

`v1` is the hex HMAC-SHA256 of `{t}.{body}`, keyed with the signing secret Wordsmith showed you when you created the destination. Compute it over the raw request body, before any JSON parsing.

Delivery is **at least once**. Wordsmith retries a batch until your endpoint accepts it, so the same events can arrive more than once. Deduplicate by event ID.

## How Wordsmith reads your response

| Your response | What Wordsmith does |
| - | - |
| Any `2xx` | The batch is delivered. |
| `408`, `425`, `429` or `5xx` | Retries with backoff, honouring `Retry-After`. |
| `401` or `403` | Stops the destination as **Auth failed** until an admin resumes it. Use this for a bad signature or token. |
| A redirect, `404`, `405`, `410` or `415` | Stops the destination: the URL is wrong. Wordsmith does not follow redirects. |
| Another `4xx`, such as `400` or `413` | Splits the batch to find the event you refuse, quarantines that event and delivers the rest. |

Wordsmith waits up to 30 seconds for a response. Store the batch, answer, and process it afterwards.

## Example receiver

This Python receiver uses only the standard library. It verifies the signature with a 5-minute tolerance and a constant-time comparison, accepts either signing secret during a rotation, and skips batches and events it has already stored.

```python theme={null}
import hashlib
import hmac
import json
import os
import re
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

# The current signing secret, then the previous one while a rotation is in progress.
SIGNING_SECRETS = [s for s in os.environ["WORDSMITH_SIGNING_SECRETS"].split(",") if s]
TOLERANCE_SECONDS = 300
CEF_EVENT_ID = re.compile(r"(?:^|[\s|])externalId=([0-9a-fA-F-]+)")

# Use a durable store, such as a database table with a unique key, in production.
seen_batches: set[str] = set()
seen_events: set[str] = set()


def signature_is_valid(header: str, body: bytes) -> bool:
    pairs = [part.split("=", 1) for part in header.split(",") if "=" in part]
    timestamps = [value for key, value in pairs if key.strip() == "t"]
    signatures = [value for key, value in pairs if key.strip() == "v1"]
    if len(timestamps) != 1 or not signatures or not timestamps[0].isdigit():
        return False
    if abs(time.time() - int(timestamps[0])) > TOLERANCE_SECONDS:
        return False
    signed = timestamps[0].encode() + b"." + body
    for secret in SIGNING_SECRETS:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, signature) for signature in signatures):
            return True
    return False


def records(content_type: str, body: bytes) -> list[tuple[str, str]]:
    """(event_id, record) pairs from a JSON array or from CEF lines."""
    if content_type.startswith("application/json"):
        return [(event["event_id"], json.dumps(event)) for event in json.loads(body)]
    lines = body.decode("utf-8").splitlines()
    return [(match.group(1), line) for line in lines if (match := CEF_EVENT_ID.search(line))]


def store(event_id: str, record: str) -> None:
    print(event_id, record)  # Replace with a write to your SIEM or log pipeline.


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self) -> None:
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not signature_is_valid(self.headers.get("Wordsmith-Signature", ""), body):
            self.send_response(401)
            self.end_headers()
            return
        batch_id = self.headers.get("Wordsmith-Event-Id", "")
        if batch_id not in seen_batches:
            try:
                batch = records(self.headers.get("Content-Type", ""), body)
            except (ValueError, KeyError, TypeError):
                self.send_response(400)
                self.end_headers()
                return
            for event_id, record in batch:
                if event_id not in seen_events:
                    store(event_id, record)
                    seen_events.add(event_id)
            seen_batches.add(batch_id)
        self.send_response(200)
        self.end_headers()


if __name__ == "__main__":
    # Serve it behind a TLS-terminating proxy: Wordsmith only sends to https:// URLs.
    HTTPServer(("0.0.0.0", 8080), Receiver).serve_forever()
```

Wordsmith checks a new destination by sending one test event, with the action `audit_stream.verification` and made-up IDs, signed the same way. The receiver above accepts it like any other event; filter it out downstream if you prefer.

## Rotate the signing secret

1. In **Settings** > **Audit log** > **Streaming**, open the destination and select **Rotate secret**. Copy the new secret; Wordsmith shows it once.
2. Set `WORDSMITH_SIGNING_SECRETS` to the new secret, then the old one, separated by a comma.
3. For 24 hours Wordsmith signs every batch with both secrets. Remove the old secret within that time.

## Related pages

* [List Audit Events](https://wordsmithai.mintlify.site/api-reference/audit/list-audit-events.md): the event fields, and polling instead of push.
* [Microsoft Sentinel](https://wordsmithai.mintlify.site/siem/sentinel.md), [Splunk](https://wordsmithai.mintlify.site/siem/splunk.md) and [Amazon S3 and Security Lake](https://wordsmithai.mintlify.site/siem/aws.md): the packaged integrations.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.