furō Morfee

The API

Everything the builder can do, the API can do. The builder is no more than a shell around it, so you can drive the whole tool from another system.

Authentication

Every call needs an API key. Create one on API keys and send it along, in either header; both work:

X-Api-Key: mrf_a1b2c3_...
Authorization: Bearer mrf_a1b2c3_...

A key belongs to exactly one organization and grants access to nothing outside it. If it is missing or revoked, you get 401.

When something goes wrong

Every failure carries a code that does not change, in application/problem+json:

{ "code": "limit_reached", "status": 402, "title": "The plan is used up", "detail": "..." }

Branch on code and never on the sentence, which gets rewritten. Every code is listed here. The old error field is still there and carries the same sentence, so nothing written before this needs changing.

Retrying without paying twice

If a connection dies while a PDF is being made, you cannot tell whether it was made. Put a key of your own on the request and the answer is settled:

Idempotency-Key: 7d2f1a90-...

That render happens once, however many times the request arrives. A repeat gets the first answer back, byte for byte, with Idempotent-Replay: 1 on it, and is not counted or charged again. Keys are kept for 24 hours. The same key with a different body is refused (idempotency_key_reused) rather than answered with the wrong document, and a render that failed frees its key so your retry is not stuck on the failure.

How much of the month is left

Every API answer carries the room you have left, so a flow can slow down before it hits the ceiling instead of finding out at it:

RateLimit-Policy: "renders";q=2500;w=2678400
RateLimit: "renders";r=2417;t=1209600

q is the ceiling for the month, r what is left of it, t how many seconds until the month rolls over. Above your included bundle you keep going and pay per document; r counts down to the hard stop, which is the point where rendering actually stops.

Endpoints

MethodPathWhat
GET/api/statusIs the service up, which PDF engine is running, and how is the queue doing.
GET/api/templatesEvery template in your organization. Filter with ?search= or ?folder=.
GET/api/templates/{id}One template, including its source, sample JSON and bindings.
POST/api/templatesSave or update a template, and file it with folderId. Trial-rendered against your sample JSON first.
POST/api/templates/{id}/copyMake a copy, with an id of its own.
DELETE/api/templates/{id}Move a template to the bin. Calls on that id fail afterwards.
GET/api/foldersEvery folder, with how many templates sit in each.
POST/api/foldersMake a folder. POST /api/folders/{id} renames one.
DELETE/api/folders/{id}Delete a folder. What was in it stays, in no folder.
POST/api/renderThe real work: a template plus data returns a PDF.
GET/api/usageYour plan, your limits and what you have used this month.
POST/mcpThe MCP server. See the MCP page.

Making a PDF

POST https://morfee.furo.solutions/api/render
X-Api-Key: mrf_...
Content-Type: application/json

{
  "templateId": "quote-a3f9c1",
  "data": { "customer": { "name": "Example Gardens Ltd" }, "lines": [ ... ] },
  "filename": "Quote 2026-0148.pdf"
}

You get the PDF bytes back, with Content-Type: application/pdf. Three variants through the format query parameter:

?format=pdfthe default: the bytes themselves
?format=urlJSON with a download URL, valid for an hour. Easier in Power Automate
?format=htmlthe rendered document without the PDF step, useful for checking

If you would rather send a flat payload, leave out data and send the fields themselves. Everything but templateId, hbs, html, filename and format then becomes the data.

A PDF from finished HTML, with no template

If the system you are calling from already builds its own HTML, there is nothing for a template to do. Send the HTML instead of a template id and you get the PDF back:

POST https://morfee.furo.solutions/api/render
X-Api-Key: mrf_...
Content-Type: application/json

{
  "html": "<!doctype html><html><body><h1>Invoice 2026-0148</h1>...</body></html>",
  "filename": "Invoice 2026-0148.pdf"
}

Everything else is the same: the same key, the same format options, the same queue and the same counter. The page setup comes from your own CSS, so put @page { size: A4; margin: 18mm } in it, and external images have to be reachable at the moment the PDF is made.

There is a third form, and it is what the test button in the builder uses: a template that is not stored anywhere, sent along with its data.

{
  "hbs": "<h1>{{text customer.name}}</h1>",
  "data": { "customer": { "name": "Example Gardens Ltd" } }
}

Send exactly one of templateId, html and hbs. Two of them is a 400 that says which two arrived, rather than a guess about which one you meant. An hbs template is held to the same helpers as a stored one.

Saving a template without the builder

The builder turns your HTML and your bindings into a Handlebars template. If you already have one, or you want to push a template from another system, send it straight in:

POST https://morfee.furo.solutions/api/templates
{
  "name": "Quote",
  "hbs": "<!doctype html>...{{text customer.name}}...",
  "sample": { "customer": { "name": "Example Gardens" } },
  "bindings": [],
  "source": "<!doctype html>..."
}

The template is trial-rendered against your sample JSON first. If it does not render it is not stored, and you are told why. Send id along to update an existing one.

What happens to it in the builder

A template sent in this way has no bindings, because nothing was pointed at: you wrote the logic yourself. The builder makes its template out of the design plus the bindings, so it cannot make this one again, and it no longer pretends otherwise.

Opening it goes to a reading screen. On the left the document as this server renders it with the sample JSON you stored with it, on the right what the template does, in words, worked out from the template itself. The same explanation sits on the template's page in the library, so you do not have to open the builder to read it. The save button is replaced by Save as a new template: the original is changed where it is written, over the API or the MCP.

A save that would replace such a template with something that has no logic left in it is refused with 409 would_drop_logic, because that is nearly always a design landing on top of a working template rather than a new version of it. Send "replaceLogic": true when you mean it. Every save keeps the previous version either way, and the template's page can put one back.

GET /api/templates/{id} carries both: byHand says whether the builder could rebuild it, and explanation is that same list of sentences, each with a depth and a text.

Keys, scopes, and how a machine gets one of its own

A key can make a key. That is what makes Morfee driveable by something automated: it can mint a narrower key for a flow, rotate the one it holds, and revoke one that has leaked, without anybody opening a screen.

POST https://morfee.furo.solutions/api/keys
X-Api-Key: mrf_...

{ "name": "invoice flow", "scope": "render", "expiresInDays": 90 }

The answer carries the key once and never again; only its hash is kept here. A key can never make a wider key than itself, otherwise a read-only key would be a full key with one extra call in front of it.

readread anything: templates, folders, versions, the bin, your usage
renderread, and make documents
writeread, and change templates and folders. Cannot render
fulleverything, including making and revoking keys

render is the one to reach for in production: a flow that makes documents has no business editing the template it renders, so a key that leaks is boring. GET /api/keys lists them with the prefix and never the secret, and POST /api/keys/{id}/revoke revokes one. Revoking the key you are calling with is allowed on purpose, because that is exactly what you want when it has leaked.

The one thing a key cannot do is create an organization. An account is a customer and a customer is an agreement, so that stays with a person. Everything after that first minute is machine work. Fetch /openapi.json for the callable surface, or /docs.md for this page as markdown.

The eight helpers, and there are only eight

A template may call these and the three Handlebars blocks if, unless and each. Anything else is refused when you save, with a 400 that names what it did not recognise. So if you are writing a template by hand, this is the whole vocabulary. There is deliberately no arithmetic: a line total is a field you send, not a sum the template makes.

text {{text price "{\"format\":\"amount\",\"before\":\"EUR \"}"}}
A value with a format, something in front, something after, and a fallback when it is empty. The spec is JSON: format, before, after, fallback. Prefix and suffix appear only around a real value, so an empty field does not come out as a lonely "EUR".
format {{format date "date"}}
Format only, the readable short form. One of amount (1.234,50), number (1.234,5), date (07-08-2026), url (percent-encoded), or left out for the value as it stands. Dutch notation. url is for a value that goes inside another link, a product URL in a QR code being the case that made it: Handlebars escapes for HTML, which is a different question, so without it the ? and the & of your link cut the outer URL in half at the first parameter.
rows {{#each (rows lines "{\"limit\":5}")}}
Filter, sort and limit a list before you walk it. The spec takes filters ([{"path":["vat"],"op":"greater","value":"0"}]), sort, direction (asc or desc), limit and from, counting from 1.
condition {{#if (condition discount "greater" "0")}}
One of filled, empty, is, isNot, contains, greater, less. Note that 0 counts as filled: a discount of nothing is a value, not an absence.
calc {{calc lines "sum" "linetotal"}}
Over a list: count, sum, average, max, min. The field is a dotted path, and count does not take one.
math {{math (calc lines "sum" "total") "percent" "10"}}
One sum on one number: percent (that many percent of it), times, divide, plus, minus. For the two things a document does to a number it already has: a deposit that is a share of the total, VAT that is a percentage of the net. Deliberately one operation and no brackets: the moment a template needs those, the number belongs in your JSON. Something that is not a number comes back untouched rather than as NaN.
tree and descend {{#tree options "{\"sort\":[\"order\"]}"}} … {{descend children}} … {{/tree}}
A list whose rows hold a list of the same shape: options under options, sections under sections, folders under folders. descend renders the same block again one level down, with the same sorting and filtering, so the template says once what happens at every level and the data decides how deep that goes. Where descend sits is where the deeper levels land, and a row your {{#if}} skips takes its children with it. Inside the block you also have @level, starting at 1. It stops at 25 levels and says so, which only happens with data that points back at itself.
You do not have to write this one either: in the builder, a repeat over a list whose rows hold a list of the same shape asks how far it should go, and writes this for you.

Two traps worth knowing if you hand-write one. A helper you invent with arguments is a 400 at save time and names itself, which is what you want. A helper you invent without arguments is not a helper call at all as far as Handlebars is concerned, so {{money}} is read as a field called "money", finds nothing, and renders as empty. If a number is silently missing, that is the first thing to check.

Error codes

400something is wrong with your request, and it says what
401no key, or a revoked one
402you are at the limit of your plan
404that template does not exist, or is not yours
502the rendering itself went wrong
503the render queue is full, or your request waited too long

Power Automate

Use the HTTP action with ?format=url, then you never have to drag binary content through your flow:

Method  : POST
URI     : https://morfee.furo.solutions/api/render?format=url
Headers : { "X-Api-Key": "mrf_...", "Content-Type": "application/json" }
Body    : { "templateId": "quote-a3f9c1", "data": @{outputs('Compose_the_data')} }

The response holds url, filename and bytes. Put a second HTTP action behind it (GET on that URL) to fetch the bytes, or hand the URL to whoever has to download it.