Moji developer workspace
Build media intelligence into your product.
Integrate Moji text, voice, SSE responses, credits, webhooks, and invoices using customer API keys from the MojitoFilms platform.
{
"prompt": {
"params": {
"b2b": true,
"session_id": "website-001",
"user_message": "Recommend...",
"stream_protocol": "sse-json-v1"
}
}
}
Text
SSE JSON v1
Voice
WebSocket
Billing
Credits-key auth
Key
Portal
Request
B2B contract
Moji
SSE or voice
Need a key?
Log in to create API keys, inspect usage, and configure webhooks for your organization.
Log in to portalIntegration model
Overview
Portal key
Create and rotate customer keys inside the MojitoFilms portal.
Clean request
Send b2b: true with a customer user_id or website session_id.
Choose output
Pick HTTP text for cards and captions, or WebSocket voice when you need playable speech.
Customer user
Use a stable customer end-user id when your product has accounts.
Website bot
Use only session_id when Moji runs as a no-user website bot.
Clean contract
Send b2b: true with a scoped customer identity.
The B2B contract has two public integration paths. HTTP text requests return structured SSE events for text, cards, trailers, cinemas, and showtimes. WebSocket voice requests accept either text input or audio input and return voice-capable output. HTTP text authenticates with X-Mojito-Credits-Key. Browser WebSocket clients send the same customer key in the init frame as payment_api_key.
First request
Quickstart
- 1
Create an API key in the portal. The plaintext key is shown once.
- 2
For text and card UI, start with POST /api/v3/assistant/ and
X-Mojito-Credits-Key. - 3
Set
b2b: true, provide eithersession_idoruser_id, and send the customer message. - 4
Set
stream_protocol:sse-json-v1so every response event has the same JSON envelope.
export MOJITO_AI_BASE_URL="https://ai-dev.mojitofilms.de"
export MOJITO_CREDITS_KEY="mfk_live_replace_with_your_key"
curl -N "$MOJITO_AI_BASE_URL/api/v3/assistant/" \
-H "Content-Type: application/json" \
-H "X-Mojito-Credits-Key: $MOJITO_CREDITS_KEY" \
-d '{
"prompt": {
"id": "mojito_assistant",
"params": {
"b2b": true,
"session_id": "website-session-001",
"action": "assistant",
"user_message": "Recommend one warm science fiction movie for Friday night.",
"user_details": {},
"stream_protocol": "sse-json-v1"
}
},
"system": {},
"model": {},
"module": "mojito"
}'Keys and secrets
Authentication
HTTP
Send X-Mojito-Credits-Key for text requests.
WebSocket
Browser clients cannot set arbitrary WebSocket headers, so send payment_api_key in the init JSON frame.
- Keep customer API keys server-side whenever possible. Do not put keys in query strings or logs.
- Customer requests do not use
Authorization: Bearerfor the AI server. user_detailsis optional. If present, it must be a JSON object.null, arrays, and strings are rejected.
Fields
Payload reference
Use this section as a field dictionary after you choose an integration path. It covers the HTTP request envelope, prompt.params for text requests, and the shared user_details object. For a voice integration, use the Voice API section for the connection sequence and then come back here only for shared fields such as user_id, session_id, user_language, and user_details.
Provide at least one of user_id or session_id. If your product has customer accounts, send user_id; add session_id when you want separate conversation threads for the same customer. For a no-account website bot, send only session_id.
| Parameter | Type | Requirement | How to use it |
|---|---|---|---|
| prompt | object | Required | Required. Container for the Moji prompt id and customer-facing params. |
| prompt.id | string | Required | Required. Use mojito_assistant for the B2B assistant contract. |
| prompt.params | object | Required | Required. Holds auth mode, conversation identity, message, and optional personalization context. |
| system | object | Required | Required for compatibility. Send an empty object for the current B2B contract. |
| model | object | Required | Required for compatibility. Send an empty object unless MojitoFilms documents a model override. |
| module | string | Required | Required. Use mojito so the request routes to the Moji assistant module. |
| Parameter | Type | Requirement | How to use it |
|---|---|---|---|
| b2b | boolean | Required | Required. Must be true for customer integrations. Enables credits-key auth and customer-safe tool behavior. |
| user_id | string | Optional | Optional when session_id is present. Use your stable end-user id when your product has accounts. |
| session_id | string | Required | Required when user_id is omitted. Creates an isolated conversation thread; use a new value to reset no-user bot memory. |
| action | string | Optional | Optional, defaults to assistant. B2B currently supports only assistant. |
| user_message | string | Required | Required for HTTP text requests. The customer's natural-language prompt. |
| user_language | string | Optional | Optional, defaults to en. Supported values include en, de, fr, and es. Used to steer response language. |
| user_details | object | Optional | Optional personalization context. Must be a JSON object if sent; null, arrays, and strings are rejected. |
| stream_protocol | string | Required | Required for public B2B text UI integrations that use the documented SSE contract. Set sse-json-v1. |
| Parameter | Type | Requirement | How to use it |
|---|---|---|---|
| name | string | Optional | Optional. Lets Moji address the user naturally when appropriate. |
| language | string | Optional | Optional. Personalization hint for response language. Prefer user_language for the API-level language setting. |
| user_language | string | Optional | Optional inside user_details for personalization. Keep it aligned with params.user_language if you use it. |
| favourite_genres | string[] | Optional | Optional. Supported spelling for favorite-genre preferences. |
| favorite_movies | string[] | Optional | Optional. Supported persona context for titles the user already loves. |
| favorite_tv_shows | string[] | Optional | Optional. Supported persona context for series and shows the user already loves. |
| gender | string | Optional | Optional. Use only when it is relevant to your product experience and consented. |
| age | number | Optional | Optional. Supported persona context. Send only when it is relevant and consented. |
| custom fields | JSON values | Optional | Optional. You can include customer-specific context, but keep it minimal, consented, and non-secret. |
Example user_details
{
"name": "Sheldon",
"gender": "male",
"age": 35,
"favourite_genres": [
"Comedy",
"Fantasy",
"Science Fiction"
],
"favorite_movies": [
"Harry Potter and the Sorcerer's Stone",
"Lord of the Rings: The Fellowship of the Ring",
"The Shawshank Redemption",
"The Dark Knight",
"Star Wars: A New Hope"
],
"favorite_tv_shows": [
"Star Trek: The Next Generation",
"Game of Thrones",
"The Big Bang Theory"
]
}Treat this object as prompt context, not storage. Include only data your product is allowed to share with Moji and never include passwords, API keys, tokens, payment data, or private notes.
Text
Text API
Text requests use POST /api/v3/assistant/. B2B customer UIs should send stream_protocol: "sse-json-v1" for stable structured SSE output. Use this path when your product wants text, movie cards, trailer links, cinema options, or showtimes rather than playable speech.
- 1
Send POST /api/v3/assistant/ with Content-Type: application/json and
X-Mojito-Credits-Key. - 2
Body must include prompt.id:
mojito_assistant, module:mojito,b2b: true, one ofsession_idoruser_id, and user_message. - 3
Set
stream_protocol:sse-json-v1, parse event: moji.event frames, and stop loading at lifecycle / response.completed.
const response = await fetch(`${baseUrl}/api/v3/assistant/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Mojito-Credits-Key": process.env.MOJITO_CREDITS_KEY
},
body: JSON.stringify({
prompt: {
id: "mojito_assistant",
params: {
b2b: true,
user_id: "customer-user-123",
session_id: "customer-user-123-thread-a",
action: "assistant",
user_message: "Give me two movies similar to Arrival.",
stream_protocol: "sse-json-v1"
}
},
system: {},
model: {},
module: "mojito"
})
});Structured SSE
SSE JSON v1
Structured mode
Required for B2B text UIs that use this contract. Set stream_protocol to sse-json-v1.
One event name
Every frame is event: moji.event with one JSON data object. Route by type and subtype.
Clear lifecycle
Handle response.completed once, then close UI loading state.
sse-json-v1 returns one self-contained JSON object per SSE event. Each event can be parsed independently and rendered from its type, subtype, source, provider, and data fields. Unsupported protocol values are rejected before payment preflight or AI work begins.
event: moji.event
id: 3
data: {"protocol":"sse-json-v1","sequence":3,"turn_id":"turn_123","type":"entity","subtype":"movie","source":"moji","provider":"tmdb","collection":"movies","index":0,"data":{"item":{"id":152601,"title":"Her","year":2013},"display_text":"Her (2013): warm, thoughtful science fiction with a human center."}}
| Field | Type | Presence | Meaning |
|---|---|---|---|
| protocol | string | Always present | Always present. Always sse-json-v1 for this SSE response. |
| sequence | number | Always present | Always present. Monotonic integer starting at 1 for each response. |
| turn_id | string | Always present | Always present. Stable correlation id for all events in this request or turn. Do not treat it as a database message_id. |
| type | string | Always present | Always present. High-level category: lifecycle, text, entity, audio, or error. |
| subtype | string | Always present | Always present. Specific meaning such as assistant_message.delta, movie, trailer, or response.completed. |
| source | string | Always present | Always present. Logical emitter: user, moji, tool, or system. |
| provider | string | When applicable | When applicable. External data provider for entity events, such as tmdb, youtube, movieglu, or mojito_internal. |
| collection | string | When applicable | When applicable. Ordered entity collection such as movies, cinemas, showings, or trailers. |
| index | number | When applicable | When applicable. Zero-based position inside the collection. |
| data | object | Always present | Always present. Renderable payload as a JSON object, never a stringified JSON blob. |
data.content
Render or store the user turn when the response includes transcript text.
data.content
Append data.content to the assistant message incrementally.
data.content
Optional final text block. Reconcile or replace accumulated deltas if present.
data.item, data.display_text
Render a movie card from data.item and use data.display_text as fallback narration.
data.item
Render movies mentioned inside a movie-information answer.
data.item, data.display_text
Render a cinema option with name, address, and display copy.
data.item, data.display_text
Render cinema, movie, and screening-time listings.
data.item
Render or link to a trailer using video id, URL, title, and thumbnail.
data.finish_reason
Stop loading and finalize the answer. finish_reason is complete or error.
data.code, data.message, data.recoverable
Show an SSE-safe error after headers were sent, then wait for response.completed.
The happy path ends with exactly one response.completed event. The separate error-path example shows what Moji sends when a recoverable SSE error happens after headers are already open.
event: moji.event
id: 1
data: {"protocol":"sse-json-v1","sequence":1,"turn_id":"turn_123","type":"text","subtype":"transcript","source":"user","data":{"content":"Recommend one warm science fiction movie for Friday night."}}
event: moji.event
id: 2
data: {"protocol":"sse-json-v1","sequence":2,"turn_id":"turn_123","type":"text","subtype":"assistant_message.delta","source":"moji","data":{"content":"If you want a softer Friday-night mood, start here."}}
event: moji.event
id: 3
data: {"protocol":"sse-json-v1","sequence":3,"turn_id":"turn_123","type":"text","subtype":"assistant_message.completed","source":"moji","data":{"content":"If you want a softer Friday-night mood, start here."}}
event: moji.event
id: 4
data: {"protocol":"sse-json-v1","sequence":4,"turn_id":"turn_123","type":"entity","subtype":"movie","source":"moji","provider":"tmdb","collection":"movies","index":0,"data":{"item":{"id":152601,"title":"Her","media_type":"movie"},"display_text":"Her (2013): warm, thoughtful science fiction with a human center."}}
event: moji.event
id: 5
data: {"protocol":"sse-json-v1","sequence":5,"turn_id":"turn_123","type":"entity","subtype":"mentioned_movie","source":"moji","provider":"tmdb","collection":"mentioned_movies","index":0,"data":{"item":{"id":329865,"title":"Arrival","media_type":"movie"}}}
event: moji.event
id: 6
data: {"protocol":"sse-json-v1","sequence":6,"turn_id":"turn_123","type":"entity","subtype":"cinema","source":"moji","provider":"mojito_internal","collection":"cinemas","index":0,"data":{"item":{"id":"cinema-6","name":"Cinema 6","address":"Example Street 1"},"display_text":"Cinema 6: a reliable nearby option."}}
event: moji.event
id: 7
data: {"protocol":"sse-json-v1","sequence":7,"turn_id":"turn_123","type":"entity","subtype":"showtime","source":"moji","provider":"movieglu","collection":"showings","index":0,"data":{"item":{"cinema_name":"Cinema 6","title":"Raiders of the Lost Ark","showtimes":[{"start_time":"18:00","end_time":"20:00"}]},"display_text":"Cinema 6: Raiders of the Lost Ark at 18:00."}}
event: moji.event
id: 8
data: {"protocol":"sse-json-v1","sequence":8,"turn_id":"turn_123","type":"entity","subtype":"trailer","source":"moji","provider":"youtube","collection":"trailers","index":0,"data":{"item":{"video_id":"abc123","title":"Her Official Trailer","url":"https://www.youtube.com/watch?v=abc123","thumbnail_url":"https://..."}}}
event: moji.event
id: 9
data: {"protocol":"sse-json-v1","sequence":9,"turn_id":"turn_123","type":"lifecycle","subtype":"response.completed","source":"system","data":{"finish_reason":"complete"}}
event: moji.event
id: 1
data: {"protocol":"sse-json-v1","sequence":1,"turn_id":"turn_456","type":"error","subtype":"upstream_unavailable","source":"system","data":{"code":"upstream_unavailable","message":"The upstream model failed.","recoverable":true,"details":{}}}
event: moji.event
id: 2
data: {"protocol":"sse-json-v1","sequence":2,"turn_id":"turn_456","type":"lifecycle","subtype":"response.completed","source":"system","data":{"finish_reason":"error"}}
Use an SSE parser that can read a POST response body. Browser EventSource only opens GET requests, so it cannot send the JSON request body shown in the quickstart.
import { createParser } from "eventsource-parser";
type MojiEvent = {
type: string;
subtype: string;
data: Record<string, unknown>;
};
const reader = response.body?.getReader();
if (!reader) throw new Error("Missing SSE response body.");
let completed = false;
const decoder = new TextDecoder();
const parser = createParser({
onEvent(message) {
if (message.event !== "moji.event" || !message.data) return;
completed = routeMojiEvent(JSON.parse(message.data)) || completed;
}
});
while (!completed) {
const { value, done } = await reader.read();
if (done) break;
parser.feed(decoder.decode(value));
}
await reader.cancel();
function routeMojiEvent(event: MojiEvent) {
if (event.type === "error") {
showSseError(event.data.message, {
code: event.data.code,
recoverable: event.data.recoverable
});
return false;
}
switch (`${event.type}:${event.subtype}`) {
case "text:assistant_message.delta":
appendAssistantText(event.data.content);
break;
case "entity:movie":
renderMovieCard(event.data.item, event.data.display_text);
break;
case "entity:cinema":
renderCinemaCard(event.data.item, event.data.display_text);
break;
case "entity:showtime":
renderShowtimeList(event.data.item);
break;
case "entity:trailer":
renderTrailer(event.data.item);
break;
case "lifecycle:response.completed":
stopLoading(event.data.finish_reason);
return true;
default:
logUnknownMojiEvent(event);
}
return false;
}{
"success": false,
"error": {
"code": "invalid_field",
"message": "Unsupported stream_protocol.",
"recoverable": false,
"details": {
"field": "stream_protocol",
"supported": ["sse-json-v1"]
}
}
}Audio
Voice API
Endpoint
Use /api/v3/assistant/ws/voice when your product needs Moji to answer with playable speech.
Protocol
Set stream_protocol to ws-json-v1 in the first JSON frame for the documented browser contract.
Input modes
Send either already-transcribed text or user audio bytes. Do not mix both in one turn.
A WebSocket session has three phases: send the init frame, send one user turn, then read events until response.completed. Text input means your frontend already has the user transcript; it does not mean text-only output. Audio input means you send user audio bytes and finish with {"type":"end_of_audio"}. In both cases, /ws/voice returns voice-capable output: text frames carry JSON events, and assistant speech arrives in binary audio frames.
- 1
Open /api/v3/assistant/ws/voice and send the init JSON frame with
b2b: true,payment_api_key,session_idoruser_id, andstream_protocol:ws-json-v1. - 2
Optionally send {
type:prewarm} before the user turn if you want the assistant state ready earlier. - 3
Send exactly one input mode: either {
type:text,text:...} or binary audio bytes followed by {type:end_of_audio}. - 4
Read JSON and binary frames until lifecycle / response.completed. Unpack binary frames before playing assistant audio.
| Parameter | Type | Requirement | How to use it |
|---|---|---|---|
| b2b | boolean | Required | Required. Set true in the first JSON init frame. |
| stream_protocol | string | Required | Required for the documented WebSocket contract. Set ws-json-v1 in the first JSON init frame. |
| payment_api_key | string | Required | Required for browser clients. Same value as X-Mojito-Credits-Key, sent in the init frame because browser WebSockets cannot set custom headers. |
| session_id or user_id | string | Required | Required. Defines the B2B memory scope together with organization and action. |
| user_language | string | Optional | Optional, defaults to en. Normalized by the server before response generation. |
| action | string | Optional | Optional, defaults to assistant. B2B supports assistant only. |
| user_details | object | Optional | Optional. Same object rules and personalization behavior as HTTP text. |
| Parameter | Type | Requirement | How to use it |
|---|---|---|---|
| type: prewarm | string | Optional | Optional before turn input. Send {type:prewarm} to initialize assistant state before the user turn. |
| type: text | string | Text input | Text input required. Send {type:text,text:...} after init or prewarm when your app already has the user transcript. |
| text | string | Text input | Text input required. Contains the already-transcribed user turn. |
| binary audio frames | bytes | Audio input | Audio input required. Send raw bytes for one complete recorded audio file or Blob, split across one or more binary frames. |
| type: end_of_audio | string | Audio input | Audio input required. Send {type:end_of_audio} after the final binary audio frame. |
Treat audio input as one recorded user turn. The server concatenates every binary frame it receives before {"type":"end_of_audio"}, then transcribes the result as a single audio file. Send raw file or Blob bytes directly; do not base64-encode them and do not wrap them in JSON. Every binary frame for that turn should be a slice of the same recording, such as a browser-recorded Blob or a WAV file read as an ArrayBuffer.
const ws = new WebSocket("wss://ai-dev.mojitofilms.de/api/v3/assistant/ws/voice");
ws.binaryType = "arraybuffer";
ws.onopen = () => {
ws.send(JSON.stringify({
b2b: true,
session_id: "website-voice-session-001",
payment_api_key: mojitoCreditsKey,
stream_protocol: "ws-json-v1",
user_language: "en",
action: "assistant",
user_details: {}
}));
// Text input: client-side STT already produced the user turn.
ws.send(JSON.stringify({
type: "text",
text: "Recommend one tense but fun movie."
}));
// Audio input instead:
// ws.send(audioArrayBuffer);
// ws.send(JSON.stringify({ type: "end_of_audio" }));
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const { metadata, pcm } = unpackMojiAudioFrame(event.data);
routeMojiVoiceEvent(metadata);
playAssistantVoice(pcm, metadata.data);
return;
}
routeMojiVoiceEvent(JSON.parse(event.data));
};
function routeMojiVoiceEvent(event) {
if (event.type === "error") {
showVoiceError(event.data.message, {
code: event.data.code,
recoverable: event.data.recoverable
});
return;
}
switch (`${event.type}:${event.subtype}`) {
case "lifecycle:prewarm.completed":
markVoiceSocketReady(event.data.duration_ms);
break;
case "text:transcript":
renderUserTranscript(event.data.content);
break;
case "text:assistant_message.delta":
appendAssistantCaption(event.data.content);
break;
case "audio:assistant_phrase":
updateMojiExpression(event.data.picture_id);
break;
case "entity:movie":
renderMovieCard(event.data.item, event.data.display_text);
break;
case "entity:cinema":
renderCinemaCard(event.data.item, event.data.display_text);
break;
case "entity:showtime":
renderShowtimeList(event.data.item);
break;
case "entity:trailer":
renderTrailer(event.data.item);
break;
case "lifecycle:response.completed":
markConversationComplete(event.data.finish_reason);
break;
default:
logUnknownMojiVoiceEvent(event);
break;
}
}
function unpackMojiAudioFrame(buffer) {
const view = new DataView(buffer);
const metadataLength = view.getUint32(0, true);
const metadataBytes = new Uint8Array(buffer, 4, metadataLength);
const metadata = JSON.parse(new TextDecoder().decode(metadataBytes));
const pcm = buffer.slice(4 + metadataLength);
return { metadata, pcm };
}| Field | Type | Presence | Meaning |
|---|---|---|---|
| protocol | string | Always present | Always present. Always ws-json-v1 for the documented WebSocket contract. |
| sequence | number | Always present | Always present. Monotonic integer starting at 1 for each WebSocket turn. |
| turn_id | string | Always present | Always present. Stable correlation id shared by all events in one turn. |
| type | string | Always present | Always present. High-level category: lifecycle, text, entity, audio, or error. |
| subtype | string | Always present | Always present. Specific meaning such as transcript, assistant_message.delta, assistant_phrase, or response.completed. |
| source | string | Always present | Always present. Logical emitter: user, moji, or system. |
| provider | string | When applicable | When applicable. External data provider for entity events, such as tmdb, youtube, movieglu, or mojito_internal. |
| collection | string | When applicable | When applicable. Ordered entity collection such as movies, cinemas, showings, or trailers. |
| index | number | When applicable | When applicable. Zero-based position inside the collection. |
| data | object | Always present | Always present. Renderable payload as a JSON object, never a stringified JSON blob. |
Text WebSocket messages are JSON envelopes. Binary assistant audio messages are packed as 4-byte uint32 little-endian metadata length header, then the UTF-8 JSON envelope, then raw pcm_s16le_22050 bytes. The audio payload is mono signed 16-bit little-endian PCM at 22,050 Hz. The audio envelope uses type: "audio", subtype: "assistant_phrase", and data.phrase, data.picture_id, and data.audio_format.
WebSocket ws-json-v1 event types
data.status, data.duration_ms
Only sent when the client sends type: prewarm before turn input. Wait for this before sending the user turn.
data.content
Render or store the normalized user turn. Text input and server-side audio transcription both produce this event.
data.content
Supplemental assistant text. Voice clients may append it as captions, but playable speech arrives as audio / assistant_phrase.
data.phrase, data.picture_id, data.audio_format
Assistant voice payload. This event is the JSON metadata inside a binary frame; PCM bytes follow that metadata in the same frame.
data.item, data.display_text
Render a movie card from data.item and use data.display_text as fallback narration.
data.item
Render movies mentioned inside a movie-information answer.
data.item, data.display_text
Render a cinema option with name, address, and display copy.
data.item, data.display_text
Render cinema, movie, and screening-time listings.
data.item
Render or link to a trailer using video id, URL, title, and thumbnail.
data.finish_reason
Stop loading and finalize the turn. finish_reason is complete, error, or empty_transcript.
data.code, data.message, data.recoverable
Show the error, then wait for lifecycle / response.completed if the socket remains open.
Billing behavior
Credits and usage
| Workflow | Units | Portal display |
|---|---|---|
| Text HTTP request | 100 | 1 credit |
| WebSocket text turn | 100 | 1 credit |
| WebSocket audio turn | 150 | 1.5 credits |
Validation and preflight failures do not charge. Successful AI work and disconnects after AI work starts are finalized with the applicable credit cost.
Failure modes
Errors and rate limits
{
"success": false,
"error": {
"code": "missing_field",
"message": "B2B requests require either user_id or session_id.",
"recoverable": false,
"details": {}
}
}400 missing_field
A required field is absent.
400 invalid_field
A field has the wrong type or value.
400 invalid_field stream_protocol
Unsupported stream_protocol values are rejected before payment preflight or AI work.
400 unsupported_action
B2B currently supports action=assistant.
401 invalid_api_key
The credits key is missing or invalid.
402 insufficient_credits
The organization cannot cover the request.
429 rate_limited
The key exceeded its request-per-minute limit.
429 overage_limit_exceeded
Billing overage policy blocked the request.
503 upstream_unavailable
Payment validation is temporarily unavailable.
Outbound events
Webhooks
Webhook endpoints are managed through the portal API and return a signing secret once. Store that secret and verify the live x-mojito-signature header on every delivery. Reject signatures outside the 5-minute replay tolerance. Delivery detail redacts historical signature values by design.
curl "https://pay-dev.mojitofilms.com/v1/webhooks/endpoints" \
-H "Authorization: Bearer $PORTAL_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/mojito",
"event_subscriptions": ["payment.succeeded", "org.suspended"],
"description": "Production webhook receiver"
}'import crypto from "node:crypto";
export function verifyMojitoSignature({
rawBody,
header,
signingSecret,
toleranceSeconds = 300
}: {
rawBody: Buffer;
header: string;
signingSecret: string;
toleranceSeconds?: number;
}) {
const parts = Object.fromEntries(
header
.split(",")
.map((part) => part.split("=", 2))
.filter(([key, value]) => key && value)
.map(([key, value]) => [key.trim(), value.trim()])
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (
!Number.isInteger(timestamp) ||
typeof signature !== "string" ||
!/^[a-f0-9]{64}$/i.test(signature)
) {
return false;
}
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (ageSeconds > toleranceSeconds) {
return false;
}
const signedPayload = Buffer.concat([
Buffer.from(String(timestamp)),
Buffer.from("."),
rawBody
]);
const expected = crypto
.createHmac("sha256", signingSecret)
.update(signedPayload)
.digest("hex");
const received = Buffer.from(signature, "hex");
const computed = Buffer.from(expected, "hex");
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}Billing records
Invoices
The public portal API lists issued invoices for the authenticated organization. Supported status filters are issued, paid, partially_refunded, refunded, and storno. PDF downloads redirect to a short-lived signed URL.
curl "https://pay-dev.mojitofilms.com/v1/invoices?status=paid" \
-H "Authorization: Bearer $PORTAL_ACCESS_TOKEN"Release notes
Changelog
2026-06-12: Documented the current B2B text, SSE JSON v1, customer memory, credits-key authentication, and WebSocket voice contract.
Public signup, generated OpenAPI reference pages, German docs, and long-term memory controls remain future docs work.