6.8 KiB
Share API bot integration
Use this endpoint when a trusted Discord bot needs to publish a member's text attachment and return a public Erika Share link.
POST https://erika.sudloh.com/api/share
Authorization: Bearer <SHARE_BOT_SECRET>
Content-Type: multipart/form-data
The endpoint does not use a NextAuth session or a public API key. It is private and
requires the shared bearer secret configured on the website as SHARE_BOT_SECRET.
Store the same secret in the bot's private environment; never place it in source
code, Discord messages, browser code, or logs.
Required website configuration
The website needs these values before the endpoint can work:
SHARE_BOT_SECRET: long random secret shared only with the bot.BASE_URL, orNEXTAUTH_URLas its fallback: canonical website origin used in returned links.- Discord bot token and guild ID already used by Erika's Discord integration.
- Redis configuration for rate limiting.
The bot can use its own variable names:
ERIKA_BASE_URL=https://erika.sudloh.com
ERIKA_SHARE_BOT_SECRET=replace-with-the-same-secret
Do not include a trailing slash in ERIKA_BASE_URL.
Multipart fields
| Field | Required | Contract |
|---|---|---|
file |
Yes | Non-empty UTF-8 .txt file, maximum 5 MiB. Blank text and NUL characters are rejected. |
senderDiscordId |
Yes | message.author.id, as a 15–22 digit Discord snowflake. Do not send the bot's ID. |
description |
No | Plain text, maximum 5,000 characters. |
image |
No | PNG, JPEG, WebP, or GIF, maximum 50 MiB. The declared MIME type must match the file contents. |
Do not send displayName, avatarUrl, authorName, or authorAvatarUrl. The API
rejects those fields. It verifies that senderDiscordId belongs to the configured
Discord guild and fetches the member's global display name and global avatar directly
from Discord.
Discord.js example
This example uses the built-in fetch, FormData, and Blob APIs available in
modern Node.js runtimes. It publishes the first .txt attachment on a message and
uses the message text as the optional description.
import type { Attachment, Message } from "discord.js";
type ShareSuccess = {
success: true;
share: {
id: string;
url: string;
author: {
displayName: string;
avatarUrl: string | null;
};
imageUrl: string | null;
createdAt: string;
};
};
type ShareFailure = {
success: false;
error: string;
};
async function attachmentBlob(attachment: Attachment) {
const response = await fetch(attachment.url);
if (!response.ok) {
throw new Error(`Unable to download ${attachment.name}: ${response.status}`);
}
return new Blob([await response.arrayBuffer()], {
type: attachment.contentType ?? "application/octet-stream",
});
}
export async function publishTextAttachment(message: Message) {
if (message.author.bot) return;
const textAttachment = message.attachments.find((attachment) =>
attachment.name?.toLowerCase().endsWith(".txt"),
);
if (!textAttachment?.name) return;
const baseUrl = process.env.ERIKA_BASE_URL;
const secret = process.env.ERIKA_SHARE_BOT_SECRET;
if (!baseUrl || !secret) {
throw new Error("Erika Share API is not configured");
}
const form = new FormData();
form.set("file", await attachmentBlob(textAttachment), textAttachment.name);
form.set("senderDiscordId", message.author.id);
const description = message.content.trim();
if (description) form.set("description", description.slice(0, 5_000));
const response = await fetch(`${baseUrl}/api/share`, {
method: "POST",
headers: {
Authorization: `Bearer ${secret}`,
},
body: form,
});
const result = (await response.json()) as ShareSuccess | ShareFailure;
if (!response.ok || !result.success) {
const retryAfter = response.headers.get("retry-after");
const retryNote = retryAfter ? `; retry after ${retryAfter}s` : "";
throw new Error(`Erika Share failed (${response.status}): ${result.error}${retryNote}`);
}
await message.reply(result.share.url);
return result.share;
}
Do not manually set the Content-Type request header. FormData supplies the
required multipart boundary automatically.
To include the first supported image attachment, add it before the request:
const imageAttachment = message.attachments.find((attachment) =>
attachment.contentType?.startsWith("image/"),
);
if (imageAttachment?.name) {
form.set("image", await attachmentBlob(imageAttachment), imageAttachment.name);
}
cURL example
curl --fail-with-body \
-X POST "https://erika.sudloh.com/api/share" \
-H "Authorization: Bearer $ERIKA_SHARE_BOT_SECRET" \
-F "file=@./message.txt;type=text/plain" \
-F "senderDiscordId=123456789012345678" \
-F "description=Optional context for this share"
Successful response
The API returns HTTP 201:
{
"success": true,
"share": {
"id": "a1b2c3d4",
"url": "https://erika.sudloh.com/share/a1b2c3d4",
"author": {
"displayName": "Example User",
"avatarUrl": "https://cdn.discordapp.com/avatars/..."
},
"imageUrl": null,
"createdAt": "2026-08-16T00:00:00.000Z"
}
}
The bot should post share.url. The shared page is public and does not require the
recipient to log in.
Error response
Failures use this shape:
{
"success": false,
"error": "Human-readable error"
}
| Status | Meaning |
|---|---|
400 |
Missing/malformed multipart data, invalid sender ID, blank text, invalid UTF-8, NUL content, or a supplied profile field. |
401 |
Missing or incorrect bearer secret. |
413 |
Text, description, or image exceeds its limit. |
415 |
The main file is not .txt, or the image type/content is unsupported. |
422 |
The sender is not a member of the configured Discord guild. |
429 |
A rate limit was exceeded. Read the Retry-After response header. |
503 |
Bot API configuration, Discord profile lookup, or Redis rate limiting is unavailable. |
500 |
Unexpected internal failure. Log the status and error without logging the bearer secret. |
Rate limits
- 60 authenticated bot API requests per hour globally.
- 10 shares per hour per Discord sender, combined across bot and website uploads.
On HTTP 429, wait for the number of seconds in Retry-After before retrying. Do
not retry 400, 401, 413, 415, or 422 automatically. A short bounded retry
with backoff is appropriate for 503 and transient 500 responses.
Identity and duplicate safety
The website creates or updates the member's linked NextAuth user and Discord account inside a transaction. Repeated or concurrent uploads for one Discord ID resolve to the same user/account pair. If that member later signs in through Discord OAuth, the login reuses the bot-created user instead of creating a duplicate.