MishkanMishkan

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

RouteScopeLevel required
GET /api/users/meposts:read
GET /api/businessesposts:read
GET /api/citiesposts:read
GET /api/posts/mineposts:read
GET /api/feed?kind=visual|textposts:read1 (verified email)
GET /api/posts/{id}posts:read1 (verified email)
POST /api/postsposts:write3 to publish on the public feed
POST /api/posts/visualposts:write3 to publish on the public feed
POST /api/posts/youtubeposts:write3 to publish on the public feed
DELETE /api/posts/{id}posts:writeauthor or admin
POST /api/notificationsnotifications: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:

FieldMeaning
contentup to 4000 characters
link_urloptional absolute http(s) URL, unfurled and shown as a card
languagefr | en | he — the feed's language filter
citycity slug — the feed's city filter
business_idoptional; must match the key's identity when it is pinned
conversation_idoptional; 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.

FieldMeaning
titlerequired, up to 120 characters
bodyup to 500 characters
sourcewho is speaking (60 characters). A key pinned to a business defaults to that business's name
levelinfo | warning | error — drives the icon, never the wording
urloptional http(s) address the notification opens
decision_urloptional http(s) address: turns the notification into a decision request (below)
dedupe_keycollapses 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" }
FieldMeaning
stateopen (a choice is awaited) or closed (decided: no more buttons). Required
titlethe question, or the title of what is being examined. Required
summary, messagethe short (plain text) and the long (Markdown), optional
statuswhere the request stands, in your words; tone: pending | success | danger | warning | neutral (drives the icon)
choicesthe buttons, in your order; style: primary (filled) | destructive (red) | default; confirm: ask for confirmation with this text before sending
mediaimages and videos to look at; consecutive images sharing a label become a swipeable strip
links, noteslinks 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

LimitValue
Reads~60 per minute, burst 30
Writes~20 per minute, burst 5
Per IP address~300 per minute, burst 60
Request size30 MB (App Engine caps at 32 MB)
Media per post10
Live keys per account20

Errors

{ "error": "missing the posts:write scope" }
StatusWhat it means
401 invalid API keyunknown, revoked or expired — the three are answered alike
403 missing the posts:write scopethe key is read-only
403 verification level 3 requiredthe account is not mezuzah-verified, which the public feed requires
403 can only publish as the business account it is bound tothe request named an identity other than the key's
402not enough מ to pay for the post
429rate limited — back off and retry
429 at most 60 notifications per daythe 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.