service api
Wardstone API
Send chat messages in. Receive signed verdicts at your endpoint.
POST/v1/messages
In order per conversation. A repeated message_id is ignored, so retries are safe. Up to 1,000 messages per batch.
POST /v1/messages
{ "messages": [
{ "conversation_id": "c1", "message_id": "m91", "author": "u7",
"text": "...", "ts": 1791712345123 } ] }
200
{ "results": [ { "conversation_id": "c1", "author": "u7",
"tier1_score": 0.71, "escalated": true } ],
"dropped_escalations": 0 }
GET/v1/stream
The same ingest as a binary WebSocket stream on the same port. One WebSocket binary message = one SBE message (schema id 22355, v1, little-endian). Send batches without waiting; each IngestAck echoes its seq, in order.
wire layout
header blockLength u16 · templateId u16 · schemaId u16 = 22355 · version u16 = 1
IngestBatch (templateId 1, client → server)
seq u64
messages blockLength u16 · numInGroup u16, then per message:
ts i64 · conversationId · messageId · author · text // each: u16 length + UTF-8 bytes
IngestAck (templateId 2) seq u64 · droppedEscalations u32 · duplicates u32 · results { tier1Score f64 · escalated u8 · conversationId · author }
Error (templateId 3) seq u64 · status u16 (400 / 413 / 500) · message
encode a batch (C++, codecs generated by sbe-tool from wardstone-schema.xml)
using namespace wardstone::wire;
char buf[64 * 1024];
IngestBatch batch;
batch.wrapAndApplyHeader(buf, 0, sizeof(buf)).seq(++seq);
auto& m = batch.messagesCount(2);
m.next().ts(1791712345123)
.putConversationId("c42").putMessageId("1001").putAuthor("u7").putText("...");
m.next().ts(1791712346001)
.putConversationId("c42").putMessageId("1002").putAuthor("u9").putText("...");
ws.sendBinary(buf, MessageHeader::encodedLength() + batch.encodedLength()); // ws://host:8090/v1/stream
decode the ack
MessageHeader h(frame, 0, len, 1);
if (h.templateId() == IngestAck::sbeTemplateId()) {
IngestAck ack;
ack.wrapForDecode(frame, MessageHeader::encodedLength(), h.blockLength(), h.version(), len);
inflight.erase(ack.seq()); // acks arrive in seq order
auto& r = ack.results();
while (r.hasNext()) {
r.next();
if (r.escalated() == BooleanType::T) { /* r.tier1Score(), r.getAuthorAsString() */ }
}
} else if (h.templateId() == Error::sbeTemplateId()) { /* status 400/413: batch rejected, connection stays open */ }
Verdict
POSTed to your configured sink when a user is flagged or their level rises. Deduplicate by verdict_id.
{ "verdict_id": "...", "author": "u7", "flagged": true,
"level": "high", "confidence_pct": 98.6,
"windows": [ { "conversation_id": "c1", "p": 0.981, "message_ids": ["m67", "m91"] } ],
"evidence": { "c1": [ { "message_id": "m67", "author": "u7", "text": "...", "ts": 1791712345123 } ] },
"artifact_version": "2026.10.1", "created_at": 1791712399000 }
Signature
X-Wardstone-Signature: sha256=<HMAC-SHA256> over the raw, zstd-compressed body. Verify, then decompress.
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(headers["X-Wardstone-Signature"], expected): return 401
verdict = json.loads(zstandard.ZstdDecompressor().decompress(body))