DocsPanel webhooks
Panel webhooks
Send every application to your own system: a sheet, a staff tool, a bot of yours. You give a panel one HTTPS address. This page is what Mico sends to it.
How it fits together
1
You set the address
In the panel's Messages and after a decision part, in the dashboard or in /panel: an https:// address and, if you like, a value for an auth header.
2
Something happens
An application is sent, accepted or denied. Each of the three can be switched off on its own.
3
Mico calls you
A signed POST within seconds, tried again if your server does not answer.
Webhooks come with the paid plans. Which ones is on the pricing page.
The events
| Event | Sent when |
|---|---|
application.submitted | An application was sent, after the AI scored it (or could not) and the review message was posted. |
application.accepted | A reviewer, or the AI's automatic decision, accepted it. |
application.denied | A reviewer, or the AI's automatic decision, denied it. |
How it is delivered
- A
POSTwith a JSON body. Answer with any2xxstatus; the body of your answer is not read. - Your server has 10 seconds to answer. Redirects are not followed: give the final address.
- Tried again when there is no answer, a timeout,
408,425,429or any5xx: after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours, six tries in all. ARetry-Afterheader in seconds is respected, up to an hour. Any other answer is final and the delivery is marked failed. - Switched off after 10 deliveries in a row failed. What was still waiting is not sent, and the server's owner gets a message from Mico. Saving the webhook again, or pressing Turn on, starts it again.
- The same event can arrive more than once, for example a retry after your server answered too late. Use
X-Mico-Delivery, orapplication.idwithevent, to recognise a repeat. - Every delivery is in the panel's delivery log, in the dashboard and in /panel, for 30 days.
The headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Mico-Webhooks/2.0 |
X-Mico-Event | The event, for example application.submitted. |
X-Mico-Delivery | The delivery's id: the same on every try of one delivery. |
X-Mico-Signature | t=<unix seconds>,v1=<hex>. See Check that it came from Mico. |
| Your auth header | Authorization, Authorization: Bearer ... or X-API-Key, when you set one. |
The body
{
"event": "application.submitted",
"timestamp": "2026-10-13T18:40:12.345678+00:00",
"application": {
"id": 1234,
"panel_id": 56,
"user_id": 111111111111111111,
"guild_id": 222222222222222222,
"panel_name": "Staff applications",
"panel_color": "#5865F2",
"total_answers": 5,
"elapsed_time_seconds": 640,
"quality_score": 82,
"ai_detection_score": 9,
"ai_reasoning": "Clear, specific answers ...",
"auto_decision": "none",
"auto_decision_reason": "n/a",
"answers": [
{ "question": "Why do you want to join?", "answer": "Because ...", "is_identifier": false }
]
}
}answersis onapplication.submittedonly: every question in the panel's order, as it was when the application was sent. An unanswered optional question has"".quality_score,ai_detection_scoreandai_reasoningarenullwhen the panel does not use the AI or the month's AI scores are used up.auto_decisionisaccept,denyornone.application.acceptedandapplication.deniedcarrydecider_id(Mico's own id for an automatic decision),is_autoandreason(the reviewer's note, ornull).- A test send (the Send a test button) has
"test": trueand anapplication.idofnull. timestampis when the event happened: every try of one delivery sends the same bytes.- Discord ids are numbers above 2^53. Read them as text if your language loses precision on big numbers.
A Discord webhook address (https://discord.com/api/webhooks/...) gets a short message in the channel instead of this body, with no signature.
Check that it came from Mico
Each panel webhook has a signing secret (whsec_...), shown in the panel's webhook settings in the dashboard, or under Deliveries and secret in /panel. v1 is the HMAC-SHA256, with the secret as the key, of t, a dot, then the raw body bytes. To check a request:
- Read the raw body, before any JSON parsing.
- Compute
HMAC-SHA256(secret, t + "." + body)and compare it withv1in constant time. - Refuse a t more than 5 minutes from your clock: it is an old request sent again.
import hashlib, hmac, time
def verify(secret: str, signature_header: str, body: bytes) -> bool:
try:
parts = dict(item.split("=", 1) for item in signature_header.split(","))
timestamp, given = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)const crypto = require("crypto");
function verify(secret, signatureHeader, rawBody) { // rawBody: a Buffer
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody])).digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}New secret makes a new one at once, and the old one stops working, so give the new one to your server right away.