Call an API the way you call a function.
Web Function is a small convention for HTTP APIs. The path names the action. A JSON object carries the arguments. The response is the return value.
POST https://api.example.com/find-user-by
200Request
{ "email": "ada@example.com" }
Response
{ "id": "usr_18", "name": "Ada Lovelace", "email": "ada@example.com" }
200 means the body is the return value. The URL is only the function name.
What Web Function is
An endpoint is a function you invoke over HTTP. You POST to a path, send one JSON object, and read one JSON value back. A package is a JSON document that lists those functions, their arguments, their return types, and their docs, so a person, a client, or an agent can discover them.
- POST
- The only invocation method. The last segment of the path is the function name.
- JSON in
- The body is one object. Its keys are the arguments.
AcceptandContent-Typeareapplication/json. - JSON out
- The response may be any JSON value: object, array, number, string, boolean, or null.
- Status
- 200 is the return value. 400 is a bad request. Any other status is unexpected, and the client surfaces it.
Auth, pagination, errors, events, versioning, deferral, and pipelining are optional flags on top of that core. You can ship with the four rules above and add a flag when you need it.
Who it's for
People designing an API
You name the action — find-user-by, send-receipt, cancel-order — and put the data in the body. The design conversation stays on what the function does.
People calling an API
curl, fetch, Postman, and a generated client all send the same POST. If you can send a JSON object, you can call the function before any SDK exists.
People running an API
Gateways, traces, and alerts already understand a path and a status code. Web Function uses that, so the platform you have keeps doing the work.
People building agents
A tool call is a name, a JSON input, and a JSON result. An endpoint is already that shape, so each function can be exposed as a tool directly.
What using it looks like
In the browser's network panel, the row is the function name. Click it and you get a JSON object in and a JSON value out, pretty-printed. The same call is what you send from curl, fetch, Postman, or a client.
Network
https://api.example.com/find-user-by
Request
{ "email": "ada@example.com" }
Response
{ "id": "usr_18", "name": "Ada Lovelace", "email": "ada@example.com" }
Request
{ "email": "not-an-email" }
Response
[ "INVALID_EMAIL", "email must be an email address.", { "field": "email" } ]
The row name is the last path segment, so the list stays readable as the API grows. The body is the return value.
400 means the request was wrong. With the error triple flag, the body is a code, a message, and details — still JSON, still pretty-printed.
The same call, anywhere
One POST. The tool you already have is enough.
$ curl -X POST https://api.example.com/find-user-by \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com"}'
const response = await fetch("https://api.example.com/find-user-by", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ email: "ada@example.com" }), }) const user = await response.json()
require "webfunction" client = WebFunction::Client.from_package_endpoint("https://api.example.com/package") client.find_user_by(email: "ada@example.com") # => { "id" => "usr_18", "name" => "Ada Lovelace", "email" => "ada@example.com" }
import { Client } from "webfunction" const client = await Client.fromPackageEndpoint("https://api.example.com/package") await client.findUserBy({ email: "ada@example.com" }) // => { id: "usr_18", name: "Ada Lovelace", email: "ada@example.com" }
The package is a list you can edit
Each endpoint carries its own arguments. You can read this in a diff and change it by hand. Publishing the description is serving the JSON. A repeated shape can be named and referenced as object.user when you want it; a small package stays a flat list.
{
"base_url": "https://api.example.com",
"endpoints": [
{
"name": "find-user-by",
"docs": "Look up a user by email.",
"flags": ["error_triple"],
"arguments": [
{ "name": "email", "type": "string.email", "flags": ["required"] }
],
"returns": "object",
"attributes": [
{ "name": "id", "type": "string" },
{ "name": "name", "type": "string" },
{ "name": "email", "type": "string.email" }
]
}
]
}
Why use it
The shape of the call is the feature. Clients, devtools, logs, gateways, and agents all get simpler because every function is a name, a JSON object, and a status code.
-
01
Devtools show the function
The network row is the last path segment, so a long list of calls stays scannable. Open a row and the preview is pretty-printed JSON on both sides: the arguments you sent, and the value you got back. Debugging a call is reading it.
-
02
Arguments stay out of the URL
Emails, ids, and filters travel in the body. They stay out of the address bar, browser history, and the Referer header on the next navigation. The URL identifies the function. A bearer token, when you use one, travels in the Authorization header.
-
03
Logs and alerts are status codes
Group by endpoint name, then split on status. 200 returned a value. 400 was a bad request from the caller. Any other 4xx or 5xx — including a 401 or 429 added by a gateway — is unexpected, and clients surface that code. A targeted alert is a filter on those two fields.
-
04
Your gateway already understands it
A call is an ordinary POST of application/json to a stable path. URL rewrites, internal dispatch, body-size limits, and per-path rate limiting apply directly. The edge does its job with the HTTP it already speaks.
-
05
A client is a thin loop
Every API shares the wire format, so a client reads the package, POSTs to base_url plus the endpoint name, and branches on status. That is small enough to write by hand, and small enough to generate. Official clients cover Ruby, Go, PHP, JavaScript and TypeScript, C#, Python, and Java.
-
06
Agents map one endpoint to one tool
find-user-by is the tool name. The package arguments are the tool input. The 200 body is the tool result. The action is already in the name, which is the shape tool calling expects.
-
07
Packages are written by hand
Endpoints sit in a list, arguments sit on the endpoint, and you can review the file in git. Describing the API is editing JSON and serving it. There is no compile step between the document and the API.
-
08
Notifications are a stream of events
When something happens live, the package points at a Server-Sent Events URL. Each message is a JSON object with an event name. The client receives the event and acts on it. Live updates do not require a state-sync protocol. A stream URL may carry a token, because browser EventSource cannot set an Authorization header.
const source = new EventSource(eventSourceUrl) source.onmessage = (message) => { const data = JSON.parse(message.data) // data.event is the name; the other keys are the payload }
Clients and the CLI
One package feeds the clients, the generator, and the converters. github.com/webfunction-protocol publishes a client for each of these languages:
wfn validates a package, generates a typed client, and converts the same document to OpenAPI or a Postman collection.
wfn validate --url https://api.example.com/package -o report.json wfn codegen --target ruby --url https://api.example.com/package -o client.rb wfn convert --target openapi --url https://api.example.com/package -o openapi.json wfn convert --target postman --url https://api.example.com/package -o collection.json
Codegen targets: ruby, go, php, js, csharp, python, java. Convert targets: openapi, postman.
The specifications
Start with the two documents that define a call and its description. The rest are flags you opt into.
Endpoint
POST, JSON in, JSON out, and what 200 and 400 mean.
Read the specification →Package
The JSON document clients, codegen, and agents read.
Read the specification →Also: Authentication, Pagination, Error, Event, Versioning, Deferral, Pipelining, Generics.