How plugins work
- 1SubmitRegister your plugin in Quid with its URL and commands. You get a signing secret once.
- 2ChecksQuid runs automatic safety checks. Pass them and you’re listed as a community plugin.
- 3InstallA workspace admin installs it into specific channels. You receive an install token.
- 4RunPeople type your commands; Quid calls your server, and you reply or post messages.
No plugin code runs inside Quid or in people’s browsers. Your plugin only ever sees the channels an admin added it to, and admins can remove it at any time.
Quickstart
This is a complete plugin in one file with no dependencies (Node 18+). It answers /hello in any channel it’s added to.
// server.mjs: a complete Quid plugin with one command, /hello.
// Run: QUID_SIGNING_SECRET=qps_... node server.mjs
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
const SECRET = process.env.QUID_SIGNING_SECRET ?? ''
const installs = new Map() // installId -> { token, apiBase } (use a database in production)
function signedByQuid(req, raw) {
const ts = req.headers['x-quid-timestamp']
const sig = req.headers['x-quid-signature'] ?? ''
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const want = Buffer.from('sha256=' + createHmac('sha256', SECRET).update(ts + '.' + raw).digest('hex'))
const got = Buffer.from(sig)
return want.length === got.length && timingSafeEqual(want, got)
}
createServer(async (req, res) => {
let raw = ''
for await (const chunk of req) raw += chunk
const body = JSON.parse(raw || '{}')
const reply = (status, json) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(json ?? {}))
}
// Quid checks you own the server before you have a secret, so this one is unsigned.
if (req.url === '/quid/verify') return reply(200, { challenge: body.challenge })
if (!signedByQuid(req, raw)) return reply(401, { error: 'bad signature' })
switch (req.url) {
case '/quid/install':
installs.set(body.installId, { token: body.token, apiBase: body.apiBase })
return reply(200)
case '/quid/uninstall':
installs.delete(body.installId)
return reply(200)
case '/quid/command':
if (body.command === 'hello')
return reply(200, { visibility: 'channel', text: `👋 Hi **${body.user.name}**!` })
return reply(200, { visibility: 'ephemeral', text: 'Unknown command' })
default:
return reply(404, { error: 'not found' })
}
}).listen(3000, () => console.log('Plugin listening on :3000'))- Put it on a public HTTPS address (Cloud Run, Fly, Render, a VPS…).
- In Quid, open Plugins → Submit a plugin, enter that address as the base URL and add a
hellocommand. - Copy the signing secret you’re shown into
QUID_SIGNING_SECRETand restart. - Install it into a channel and type
/hello.
Submitting
A submission has:
| Field | Rules |
|---|---|
| Name | 2–40 characters. Can’t use “Quid” or “official”. |
| Tagline | 4–90 characters, plain text, no links. |
| Description | Up to 2,000 characters. |
| Developer name and email | The name is shown on the listing; the email is only used to contact you. |
| Base URL | Public HTTPS. Quid calls {baseUrl}/quid/…. |
| Icon URL | Optional PNG, JPEG, WebP or GIF under 512 KB. Copied into Quid. |
| Commands | Up to 10. Names are 2–32 lowercase letters, numbers or dashes. |
| Events | message.created |
| Private | Enterprise+ workspaces can keep a plugin to themselves. |
Automatic safety checks run right away:
- The base URL is HTTPS on a public address (private networks are refused).
- Your server echoes a one-time challenge, proving you control it.
- The icon is a real image under 512 KB (SVG is refused).
- The listing doesn’t claim to be official or verified and contains no links or HTML.
- None of your URLs are on Google Safe Browsing’s list of dangerous sites.
Verifying requests
Every request from Quid (except /quid/verify) carries two headers:
x-quid-timestamp: 1791234567
x-quid-signature: sha256=<hex HMAC-SHA256(signing secret, timestamp + "." + raw body)>Compute the HMAC over the exact raw body bytes, compare in constant time, and reject timestamps more than 5 minutes old. The Node version is in the quickstart; here it is in Python:
import hashlib, hmac, time
def signed_by_quid(headers, raw_body: bytes, secret: str) -> bool:
ts = headers.get("x-quid-timestamp", "")
sig = headers.get("x-quid-signature", "")
if not ts or abs(time.time() - int(ts)) > 300:
return False
want = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)Requests from Quid
Quid sends POST requests with a JSON body to these paths under your base URL:
| Path | When | Reply |
|---|---|---|
/quid/verify | During the safety checks | { "challenge": "<same value>" } |
/quid/install | Added to a workspace. Body has installId, workspace, channels, token, apiBase | Any 2xx. Store the token |
/quid/uninstall | Removed from a workspace | Any 2xx. Forget the token |
/quid/command | Someone ran one of your commands | Within 5 s, see below |
/quid/events | A subscribed event happened | Any 2xx (retried with backoff) |
Slash commands
When someone types /poll Lunch? | Pizza | Sushi, you receive:
{
"type": "command",
"installId": "9b2e…",
"command": "poll",
"text": "Lunch? | Pizza | Sushi",
"user": { "id": "…", "name": "Ada Park" },
"channel": { "id": "…", "name": "general" },
"threadId": null
}Reply within 5 seconds with:
{ "visibility": "channel", "text": "📊 **Lunch?**\n- Pizza\n- Sushi" }ephemeralshows the text only to the person who ran the command (good for errors and help).channelposts it as your plugin for everyone to see.- Need longer? Reply
ephemeralright away (“Working on it…”) and post the result later with the API.
Posting messages
Use the install token and apiBase you received at install time. Plugins can post only in channels the admin added them to.
curl -X POST "$API_BASE/messages" \
-H "Authorization: Bearer $INSTALL_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "channelId": "4f1c…", "text": "Deploy finished ✅", "threadId": null }'Text is up to 4,000 characters. Pass a threadId to reply in a thread.
Events
Subscribe to message.created to see new messages in your channels (the listing tells admins you can read them). Deliveries are retried with backoff if you don’t answer 2xx, so make handlers idempotent using message.id.
{
"type": "event",
"event": "message.created",
"installId": "9b2e…",
"message": {
"id": "…", "channelId": "…", "authorId": "…", "authorName": "Ben Ortiz",
"text": "ship it", "threadId": null, "createdAt": "2026-10-07T09:12:00.000Z"
}
}Formatting
Plugin messages support a small, safe subset of Markdown:
| Write | Shows as |
|---|---|
**bold** | bold |
`code` | code |
- item | • item (a bullet list) |
| Emoji | As is 🎉 |
Review and badges
Community
Passed the automatic checks. Enterprise+ workspaces can install after a warning.
Verified
Reviewed by the Quid team. Any workspace can install it.
Private
Only the workspace that submitted it can see and install it.
The Official badge is reserved for Quid’s own plugins. The team can suspend a plugin that misbehaves, which stops it everywhere at once.
Limits
| What | Limit |
|---|---|
| Command reply | 5 seconds |
| Posting messages | 60 per minute per install |
| Message length | 4,000 characters |
| Commands per plugin | 10 |
| Signature window | 5 minutes |
Testing locally
Quid only calls public HTTPS addresses. While developing, expose your local server with a tunnel such as cloudflared tunnel --url http://localhost:3000 or ngrok http 3000, and submit the tunnel’s URL as a private plugin in your own workspace. Update the base URL when you deploy for real.