Overview
MarkdownShonin is an open source, human-in-the-loop approval API. Send an approval request to any email address and wait for a human decision before your automation continues. Approvers need no account: they just click a link.
Base URL https://shonin.dev/api/v1
All requests must include an Authorization: Bearer <api_key> header, except the /v1/decide/:token links in approval emails, which approvers open and which need no key.
Shonin is MIT licensed. Try the hosted demo at shonin.dev, or self-host it.
Quickstart
MarkdownThis quickstart uses the hosted demo, which is capped and meant for trying Shonin out. For real use, self-host Shonin.
-
Sign in with your email at /login. Your API key is on your dashboard and is emailed to you.
-
Send an approval request. Use your own email as the approver: the hosted demo only sends to your account email.
bashcurl -X POST https://shonin.dev/api/v1/approvals \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "action": "Deploy to production", "approver_email": "you@example.com" }' -
Open the email and click Approve, then read the result with the
idfrom the response:bashcurl https://shonin.dev/api/v1/approvals/APPROVAL_ID \ -H "Authorization: Bearer sk_your_api_key"
The status field changes from pending to approved or rejected.
Authentication
MarkdownPass your API key as a Bearer token in the Authorization header on every request. Sign in at /login to see your key.
curl -H "Authorization: Bearer sk_your_api_key" \
https://shonin.dev/api/v1/approvals/APPROVAL_IDKeep your API key secret. Do not expose it in client-side code or public repositories.
Creates a new approval request and sends an email to the approver with Approve and Reject buttons. Returns immediately. The approval stays pending until the approver decides.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
action | string | required | A short description of what needs approval. Shown prominently in the email. |
approver_email | string | required | The email address of the person who will approve or reject. |
context | string | optional | Optional extra context displayed in the email below the action. |
webhook_url | string | optional | Public https URL to POST the decision to when the approver clicks Approve or Reject. Private and reserved addresses are rejected with a 400. |
expires_in_hours | number | optional | How many hours before the approval link expires. Defaults to 24. |
command_type | string | optional | What kind of action this is, for example git_push_force or sql_drop. Drives the risk banner in the email. Inferred from action when omitted. See Risk Levels. |
files | array | optional | Files the action touches, for example [{ "path": "app/route.ts", "status": "modified" }]. Status is modified, added, deleted or renamed. |
diff | string | optional | A diff to show the approver. Truncated at 50KB. |
Example request
curl -X POST https://shonin.dev/api/v1/approvals \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"action": "Deploy to production",
"approver_email": "cto@company.com",
"context": "PR #247 merged, 3 files changed",
"webhook_url": "https://yourapp.com/webhooks/shonin"
}'Example response
Status 201 Created:
{
"id": "a1b2c3d4-...",
"status": "pending",
"created_at": "2026-03-21T18:00:00Z",
"expires_at": "2026-03-22T18:00:00Z"
}Returns the current state of an approval. Use this to poll for a decision if you are not using webhooks. Only approvals belonging to the authenticated account are returned.
Example request
curl https://shonin.dev/api/v1/approvals/a1b2c3d4 \
-H "Authorization: Bearer sk_your_api_key"Example response
Status 200 OK:
{
"id": "a1b2c3d4-...",
"action": "git push origin main --force",
"context": "Remote diverged after a rebase",
"approver_email": "cto@company.com",
"status": "approved",
"webhook_url": null,
"command_type": "git_push_force",
"risk_level": "DESTRUCTIVE",
"risk_bullets": [
"Will overwrite upstream commits",
"Bypasses branch protection rules"
],
"files": [{ "path": "app/route.ts", "status": "modified" }],
"expires_at": "2026-03-22T18:00:00Z",
"decided_at": "2026-03-21T18:45:00Z",
"created_at": "2026-03-21T18:00:00Z"
}Risk Levels
MarkdownWhen an approval has a command_type, the email opens with a risk banner so the approver can see how reversible the action is. If you leave command_type out, Shonin infers it from action when it recognizes commands such as git push --force, git reset --hard, rm, drop table or a migration.
| command_type | Level | Shown to the approver |
|---|---|---|
git_push_force | DESTRUCTIVE | Will overwrite upstream commits. Bypasses branch protection rules. |
git_reset_hard | DESTRUCTIVE | Local changes will be permanently lost. |
rm | DESTRUCTIVE | Files cannot be recovered from trash. |
sql_drop | DESTRUCTIVE | Table data is permanently deleted. |
sql_migration | HIGH | Schema changes may be irreversible. |
git_push | LOW | Reversible via git revert. |
git_commit | LOW | Reversible via git reset. |
| anything else | LOW | No banner text. |
DESTRUCTIVE approvals add a 3 second countdown on the confirm page before the Approve button unlocks.
Ask a multiple-choice question instead of a yes or no. The respondent gets an email with one button per option and picks one.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
question | string | required | The question to ask. |
options | array | required | 2 to 10 options, each { "key": "prod", "label": "Production" }. A key is up to 16 characters and a label up to 200. |
respondent_email | string | required | The email address of the person who will answer. |
context | string | optional | Optional extra context shown in the email. |
webhook_url | string | optional | Public https URL to POST the answer to. |
expires_in_hours | number | optional | How many hours before the links expire. Defaults to 24. |
Example request
curl -X POST https://shonin.dev/api/v1/decisions \
-H "Authorization: Bearer sk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"question": "Which environment should we deploy to?",
"options": [
{ "key": "staging", "label": "Staging" },
{ "key": "prod", "label": "Production" }
],
"respondent_email": "cto@company.com"
}'Example response
Status 201 Created:
{
"id": "e5f6a7b8-...",
"status": "pending",
"created_at": "2026-03-21T18:00:00Z",
"expires_at": "2026-03-22T18:00:00Z"
}Returns the current state of a decision. status is pending or decided, and chosen_key holds the key of the option that was picked.
{
"id": "e5f6a7b8-...",
"status": "decided",
"chosen_key": "prod",
"decided_at": "2026-03-21T18:45:00Z",
"expires_at": "2026-03-22T18:00:00Z",
"created_at": "2026-03-21T18:00:00Z"
}Webhooks
MarkdownSet webhook_url on an approval or a decision and Shonin sends a POST with a JSON body once the decision is recorded.
Approval payload
{
"id": "a1b2c3d4-...",
"status": "approved",
"decided_at": "2026-03-21T18:45:00Z"
}Decision payload
{
"id": "e5f6a7b8-...",
"status": "decided",
"chosen_key": "prod",
"decided_at": "2026-03-21T18:45:00Z"
}Rules
- The URL must be a public https address. Private and reserved addresses are rejected with a 400 when you create the request.
- Shonin waits 5 seconds for a response, does not follow redirects, and sends each webhook once with no retries.
Webhook payloads are not signed. Treat one as a signal and confirm the result with a GET request before you act on it.
How Decisions Work
MarkdownYou never call the decide endpoint yourself. It sits behind the links in the email, and it is built so the code that asks for approval cannot give it.
- The approve and reject links exist only in the email to the approver. The API never returns them, so your code cannot approve its own request.
- Opening a link never decides. It leads to a confirm page, and the decision is recorded only when the approver confirms, which sends a POST. Mail scanners and link previews that open every link cannot decide.
- Links are single-use and expire after 24 hours unless you set
expires_in_hours.
Errors
MarkdownAll errors return a JSON object with an error field containing a human-readable message.
{ "error": "Invalid API key" }Error codes
| Status | Meaning | Common cause |
|---|---|---|
| 400 | Validation error | A required field is missing, a value has the wrong type, or webhook_url is not a public https address. |
| 401 | Invalid API key | Missing or incorrect Authorization header. |
| 404 | Not found | The ID does not exist or belongs to a different account. |
Hosted demo only
The hosted demo is capped to protect a shared email allowance. Self-hosted instances do not apply these limits.
| Status | Meaning | Common cause |
|---|---|---|
| 403 | Recipient not allowed | approver_email or respondent_email is not the email on your account. |
| 429 | Daily limit reached | Your key has used its requests for the day. |
| 503 | Demo budget used | The shared email budget for today is used up. Try again tomorrow. |
Self-Hosting
MarkdownShonin is MIT licensed and runs on Next.js, Supabase and Resend. A self-hosted instance has no request limits, and it serves this same documentation at /docs.
The full setup guide is in the README on GitHub: Self-hosting guide.