API
Publish on Mishkan from outside the app.
Updated 2026-09-11
A Mishkan API key lets a studio, a script or a server read the feed and publish on it in your name. It is a second door into your own identity — not a service account: a key obeys every rule you obey, from your verification level to the Mem a post costs and the people you have blocked.
Everything below is served from https://api.mishkan.tech. Responses are JSON; errors carry an HTTP status and a single "error" string.
Getting a key
Keys are minted inside the app: Profile → API keys → +. Creating one requires level 4 (formal verification), the same bar as a business sub-account — a key acts in your name outside the app, so we hand one out only to an account an admin has identified. Listing and revoking work at any level.
- Name — for your own bookkeeping («Studio Midbar»).
- Publish as — your personal account, or one of your business sub-accounts. A key pinned to a business can publish ONLY as that business; naming another identity in a request is refused.
- Allow publishing — off, the key can only read.
The secret (msk_live_…) is shown once. The server keeps only its SHA-256, so it cannot be recovered — a lost key is revoked and replaced. You may hold up to 20 live keys, each with an optional expiry of up to ten years.
Authentication
Send the key as a bearer token. The X-API-Key header is accepted too, for clients that reserve Authorization for themselves.
curl https://api.mishkan.tech/api/users/me \
-H "Authorization: Bearer $MISHKAN_KEY"An unknown, revoked or expired key all answer the same 401 — deliberately, so probing tells an attacker nothing.
Scopes
A key carries posts:read, posts:write, notifications:write, or any combination. Everything else — messages, Mem, verification, administration, and key management itself — stays reserved to a signed-in session. A leaked key can neither mint another one, nor read your conversations, nor spend your balance.
- posts:read — read the feed, a post, your own posts, your businesses.
- posts:write — publish and delete.
- notifications:write — notify the key's owner, and only them. It is never granted by default: ask for it when you create the key, or turn it on later from the key's row in the app.
Endpoints
| Route | Scope | Level required |
|---|---|---|
| GET /api/users/me | posts:read | — |
| GET /api/businesses | posts:read | — |
| GET /api/cities | posts:read | — |
| GET /api/posts/mine | posts:read | — |
| GET /api/feed?kind=visual|text | posts:read | 1 (verified email) |
| GET /api/posts/{id} | posts:read | 1 (verified email) |
| POST /api/posts | posts:write | 3 to publish on the public feed |
| POST /api/posts/visual | posts:write | 3 to publish on the public feed |
| POST /api/posts/youtube | posts:write | 3 to publish on the public feed |
| DELETE /api/posts/{id} | posts:write | author or admin |
| POST /api/notifications | notifications:write | — (notifies its own owner) |
Publishing into a group's own feed (conversation_id) replaces the level gate with membership and the group's posting policy, and pays the group's own price to its owner.
curl "https://api.mishkan.tech/api/feed?kind=text&limit=20" \
-H "Authorization: Bearer $MISHKAN_KEY"Publishing
A post on the public feed costs 2 מ and requires mezuzah verification (level 3). The charge is refunded if the post fails to save.
POST /api/posts publishes text and links, as JSON:
| Field | Meaning |
|---|---|
| content | up to 4000 characters |
| link_url | optional absolute http(s) URL, unfurled and shown as a card |
| language | fr | en | he — the feed's language filter |
| city | city slug — the feed's city filter |
| business_id | optional; must match the key's identity when it is pinned |
| conversation_id | optional; publishes into that group's feed instead of the public one |
curl -X POST https://api.mishkan.tech/api/posts \
-H "Authorization: Bearer $MISHKAN_KEY" \
-H "Content-Type: application/json" \
-d '{"content":"Chag sameach","language":"en"}'POST /api/posts/visual publishes a reel or a carousel as multipart/form-data: 1 to 10 media files, in form order, up to 30 MB for the whole request. The caption goes in content (1000 characters), and the same optional fields apply.
curl -X POST https://api.mishkan.tech/api/posts/visual \
-H "Authorization: Bearer $MISHKAN_KEY" \
-F "content=Kabbalat Shabbat, 18:30" \
-F "media=@reel.mp4;type=video/mp4"POST /api/posts/youtube publishes a reel backed by a YouTube video. url is the video; link_url is a separate “learn more” button, so fill both when you want the two.
curl -X POST https://api.mishkan.tech/api/posts/youtube \
-H "Authorization: Bearer $MISHKAN_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://youtu.be/abc123","link_url":"https://example.org"}'Notifications
An integration that stops in the night — out of credit, expired token, quota reached — usually tells nobody: the error lands in a scheduler's logs, and you find out three days later, wondering why nothing was published. With notifications:write, a key writes into its owner's notification centre and the push goes out like any other.
A key notifies its OWNER, and nobody else. The recipient is not a field of the request — it IS the key's identity, so there is nothing to falsify to reach someone else. This channel cannot become a megaphone.
| Field | Meaning |
|---|---|
| title | required, up to 120 characters |
| body | up to 500 characters |
| source | who is speaking (60 characters). A key pinned to a business defaults to that business's name |
| level | info | warning | error — drives the icon, never the wording |
| url | optional http(s) address the notification opens |
| decision_url | optional http(s) address: turns the notification into a decision request (below) |
| dedupe_key | collapses repeats of the same alert for 6 h |
curl -X POST https://api.mishkan.tech/api/notifications \
-H "Authorization: Bearer $MISHKAN_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Autopilot stopped",
"body": "Anthropic credit exhausted — nothing published since 03:12.",
"source": "TorahWithLLM",
"level": "error",
"url": "https://console.anthropic.com/settings/billing",
"dedupe_key": "anthropic-credit"
}'202 on delivery. A deduped repeat answers 200 with deduped: true and the id of the notification already sent — the request succeeded and nothing was sent:
{ "delivered": true, "remaining_today": 59 }
{ "id": "…", "delivered": false, "deduped": true, "remaining_today": 59 }dedupe_key is what makes an alert loop bearable: a pilot retrying every ten minutes with no credit would otherwise send the same line 144 times a day. Under it sits a hard floor of 60 notifications per account per 24 h, after which the route answers 429; remaining_today comes back on every call so you can back off first.
Title and body are shown exactly as sent, in every language: the server does not know what your integration meant, and guessing it in three languages would be inventing. A key pinned to a business notifies under that identity — the alert shows up in the app under that account, not in the personal centre — and dedupe_key is scoped to it too, so two businesses using the same key describe two different failures.
Decision requests
An integration sometimes needs a human answer before acting: publish what the autopilot just produced, or not; retry or give up; pick one of three routes. With decision_url, the notification does not open a page — it opens a decision screen in the app: the question, whatever there is to look at first (text, images, video), and the choices YOU define. Mishkan only reads the address and sends the choice back; the integration keeps the content, writes the labels and applies the answer. Reviewing an article is one use; « Restart the pilot? » with two buttons is another, and a complete one.
curl -X POST https://api.mishkan.tech/api/notifications \
-H "Authorization: Bearer $MISHKAN_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Publish this article?",
"body": "Eisenkot leaves the coalition — reel and carousel ready.",
"level": "info",
"decision_url": "https://studio.example/api/review/42?t=<token>",
"dedupe_key": "review-42"
}'The address must carry its own authentication (a token in the URL, signed by the integration): the app calls it as is, with no key. It must answer GET with the request as JSON, and POST {"choice": "<id>"} with the same request, updated. POST is idempotent: a request already decided comes back as is, whichever button was pressed.
{
"state": "open",
"title": "Publish this article?",
"summary": "The standfirst, or the question in one sentence.",
"message": "The long part, in **Markdown** — optional.",
"source": "Midbar studio",
"status": { "label": "Waiting for your approval. Nothing is published.", "tone": "pending" },
"choices": [
{ "id": "approve", "label": "Approve and publish", "style": "primary",
"confirm": "The article goes out to the site and Instagram. A few minutes." },
{ "id": "site_only", "label": "Publish on the site only" },
{ "id": "reject", "label": "Reject", "style": "destructive" }
],
"media": [
{ "kind": "video", "url": "https://…/reel.mp4", "label": "Reel" },
{ "kind": "image", "url": "https://…/slide-1.jpg", "label": "Carousel" }
],
"links": [ { "label": "Source", "url": "https://…" } ],
"notes": [ "English edition to follow: …" ]
}POST <decision_url>
{ "choice": "approve" }| Field | Meaning |
|---|---|
| state | open (a choice is awaited) or closed (decided: no more buttons). Required |
| title | the question, or the title of what is being examined. Required |
| summary, message | the short (plain text) and the long (Markdown), optional |
| status | where the request stands, in your words; tone: pending | success | danger | warning | neutral (drives the icon) |
| choices | the buttons, in your order; style: primary (filled) | destructive (red) | default; confirm: ask for confirmation with this text before sending |
| media | images and videos to look at; consecutive images sharing a label become a swipeable strip |
| links, notes | links opened outside the app, lines of information |
The app re-reads the state every time the screen opens — a request decided from another phone shows as closed, with the decision taken. An open request cannot be dismissed from the notification centre: the row is the only door to the question, and the server refuses while state is open (it re-reads the address); closed, or the service unreachable, it goes like any other row. Labels are yours, shown as sent: the app does not translate them, so write them in the recipient's language. Give each request its own dedupe_key: two questions in the same hour must ring twice.
Limits
| Limit | Value |
|---|---|
| Reads | ~60 per minute, burst 30 |
| Writes | ~20 per minute, burst 5 |
| Per IP address | ~300 per minute, burst 60 |
| Request size | 30 MB (App Engine caps at 32 MB) |
| Media per post | 10 |
| Live keys per account | 20 |
Errors
{ "error": "missing the posts:write scope" }| Status | What it means |
|---|---|
| 401 invalid API key | unknown, revoked or expired — the three are answered alike |
| 403 missing the posts:write scope | the key is read-only |
| 403 verification level 3 required | the account is not mezuzah-verified, which the public feed requires |
| 403 can only publish as the business account it is bound to | the request named an identity other than the key's |
| 402 | not enough מ to pay for the post |
| 429 | rate limited — back off and retry |
| 429 at most 60 notifications per day | the daily notification floor — use dedupe_key |
Revoking
Swipe the key (iOS) or tap the bin (Android), or call DELETE /api/api-keys/{id} with a session token. The effect is immediate. Posts already published stay online: revoking a credential is not withdrawing what was published with it.