Intents: Human Approval for Irreversible Actions
An intent is a proposed action that waits for a person. Your application says what it wants to do; a human with permission on the resource reviews it on Wefunder and approves or rejects it; only then does it run. The API uses intents for the actions that cannot be undone.
Why
Agents hallucinate and automations have bugs. In a regulated financial context a single wrong call can close a raise or remove an investor's access, and there is no undo. Intents separate who proposes from who approves: software proposes, a person approves, and the proposer can never approve its own proposal.
Which actions need an intent
| Action name | Resource | Params | What it does |
|---|---|---|---|
syndicates.remove_member | the syndicate | { "member_id" } | Remove a member from the syndicate |
syndicates.publish | the syndicate | none | Make the syndicate public |
syndicates.delete_draft | the syndicate | none | Delete an unpublished syndicate |
comments.create | Company | { "target_type", "target", "body", "disclosure_key"? } | Post a question on a company's Ask tab (target_type: "company", target: co_...) or answer one (target_type: "comment", target: cmt_...) as the requesting user. Scope write:comments. Only the requester can approve; 20 per user per day |
The syndicates.* actions require the write:syndicates scope; comments.create requires write:comments and is rolling out per account, so a token whose user is not yet enabled gets 403 feature_disabled. Everything else on the Syndicate API (inviting, approving, promoting, demoting, updating members, editing settings) is reversible and executes immediately.
Proposing goes through POST /intents. Trying to bypass the system, for example DELETE /syndicates/{id}/members/{member_id}, returns 422 with type: "use_intents".
syndicates.close_deal and syndicates.finalize_deal are not actions on Wefunder-Version: 2026-10-15 or later: POST /intents answers 400 bad_request ("Unknown action") with details.removed_in_version: "2026-10-15". A syndicate's managers close and finalize deals on wefunder.com. Applications pinned to 2026-10-01 can still propose both, with { "fundraise_id" } params, and still have the dedicated close and finalize deal endpoints that mint them. Intents minted earlier keep their action names in GET /intents.
Propose
curl -X POST "https://api.wefunder.com/intents" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action_name": "syndicates.remove_member",
"resource_type": "Club",
"resource_id": "syn_aB3xQ9k2vF8mNp1zT5wY7Qc4",
"params": { "member_id": "mem_7Hk2mP9qR4sT1uV6wX8yZ3aB" },
"idempotency_key": "remove-mem_7Hk2mP9qR4sT1uV6wX8yZ3aB-2026-09",
"requested_by_agent": "Claude via MCP"
}'
| Param | Required | Description |
|---|---|---|
action_name | yes | One of the actions above |
resource_type | yes | The literal string Club for every syndicates.* action (the API's name for a syndicate); Company for comments.create |
resource_id | yes | The syndicate's syn_... id, or the company's co_... id |
params | per action | A JSON object; see the table |
idempotency_key | no | Names this one operation; see Idempotency |
requested_by_agent | no | Shown to the reviewer as who asked |
Response 201:
{
"data": {
"id": "int_aB3xQ9k2vF8mNp1zT5wY7Qc4",
"type": "intent",
"attributes": {
"action": "syndicates.remove_member",
"status": "pending",
"resource_type": "Club",
"resource_id": "syn_aB3xQ9k2vF8mNp1zT5wY7Qc4",
"impact_summary": "Remove Jane Smith from Acme Syndicate. She will lose access to the syndicate's deals.",
"review_url": "https://wefunder.com/intents/a1b2c3d4-e5f6-7890-abcd-ef1234567890/review",
"requested_by_agent": "Claude via MCP",
"expires_at": "2026-03-06T14:30:00Z",
"approved_at": null,
"executed_at": null,
"rejection_reason": null,
"execution_result": null,
"created_at": "2026-03-05T14:30:00Z",
"updated_at": "2026-03-05T14:30:00Z"
}
}
}
Errors you may see: 400 bad_request for an unknown action, wrong resource_type, or non-object params; 403 insufficient_scope when the token lacks the action's scope; 403 feature_disabled when the action is not yet enabled for this account; 403 forbidden when a resource-bound token targets a different syndicate or the user may not propose here; 409 idempotency_conflict when the key was used for a different operation; 422 validation_error when the handler refuses the proposal, with the reason in message; 429 rate_limit_exceeded after 10 new pending intents in an hour (20 comments.create proposals per user per day).
Preview
POST /intents/preview runs every check POST /intents runs and returns what the reviewer would read, without minting anything. Send the same body without idempotency_key. It is admitted by any proposing scope or by read:explore, and the action's own scope is not required on the token, so an agent can let the user shape a proposal before the write permission is granted. The caller must still be allowed to propose on the resource (for syndicate actions, an edit-tier principal), and for a syndicate-owned token the application's Installation scope ceiling still applies: an install granted only read:syndicates cannot preview a write:syndicates action (403 insufficient_scope).
{
"data": {
"type": "intent_preview",
"attributes": {
"action": "comments.create",
"resource_type": "Company",
"resource_id": "co_aB3xQ9k2vF8mNp1zT5wY7Qc4",
"params": { "target_type": "company", "target": "co_aB3xQ9k2vF8mNp1zT5wY7Qc4", "body": "What is your runway?" },
"impact_summary": "Post a question on Acme's Ask tab as Jane Smith.",
"scope": "write:comments"
}
}
}
A refused preview is 422 validation_error with the handler's reason, the same response POST /intents would give.
Review
review_url is a page on Wefunder. The reviewer must be logged in and hold permission on the resource; the handler re-checks that permission when the page loads and again on approve, so a role change after the proposal blocks approval. The page shows the proposing application and agent, the impact summary, the exact parameters, the expiry, and Approve / Reject.
Outcome
| Status | Meaning |
|---|---|
pending | Waiting for a reviewer |
approved | Approved; execution about to start |
executing | Running |
executed | Done. execution_result is populated |
rejected | A reviewer declined. rejection_reason may be set |
expired | Nobody acted before expires_at |
failed | Approved, but execution raised |
Poll GET /intents/{id} for the outcome, or list with filters:
GET /intents?status=pending&resource_type=Club&resource_id=syn_aB3xQ9k2vF8mNp1zT5wY7Qc4
You see only intents your own application proposed for this user. The list is paginated by timestamp cursor like every other list.
Expiry
Pending intents expire 24 hours after proposal (deleting a draft syndicate expires after 12). A proposal made today should not become executable next week when the facts may have changed. Propose again if it expires.
Guarantees
- The proposal is sealed. The action, resource, requesting user, and params are hashed at proposal time and re-verified before execution; a tampered row does not run.
- Permission is re-checked at approval, not assumed from the proposal.
- Scoped visibility. An application sees only the intents it proposed for the authorizing user.
- Replay is safe. A repeated proposal with the same
idempotency_keyreturns the existing pending intent instead of a second one.
For agents
When a tool wraps an intent, return the summary and the link and stop:
I've proposed removing Jane Smith from Acme Syndicate. This won't happen until you approve it.
Review here: https://wefunder.com/intents/…/review
Expires: tomorrow at 2:30 PM.
There is no execute endpoint for the agent to call. See how intents work in practice in the syndicate quickstart.