TL;DR:
- Notion has no "append row" call. You create a page whose parent is a data source:
POST https://api.notion.com/v1/pages. - Two headers are mandatory:
Authorization: BearerandNotion-Version. Omit the version and the request fails. - Flat strings are rejected. Every value must be wrapped in the property type the schema declares — a title becomes an array of rich-text objects.
- Limits: about 3 requests per second per connection, 2,000 characters per rich-text value, 500KB per request.
- A 429 carries a
Retry-Afterheader in seconds. Read it instead of guessing.
/ Overview
How do you send Contact Form 7 entries to Notion?
You create one Notion page per submission. Hook wpcf7_mail_sent, read the values from WPCF7_Submission, convert each one into the property type its Notion column expects, and POST the result to /v1/pages with the parent set to your data source.
The mental model trips people up more than the code does. In Notion a database row is a page, so "add a row" and "create a page" are the same operation — there is no rows endpoint to look for.¹
/ Typing
Why does Notion reject a flat string?
Because Notion stores every cell as a typed structure, not a scalar. Sending "Ada Lovelace" where the schema declares a title produces a validation_error and no page. The API wants the shape the property type defines, every time, with no coercion — this is the exact opposite of Airtable's typecast behaviour, so a mapper written for one will not work for the other.
JSON — the same value in three property types
// title "Name": { "title": [ { "text": { "content": "Ada Lovelace" } } ] } // email — a plain string, unusually "Email": { "email": "[email protected]" } // select — the option is created if it does not exist "Source": { "select": { "name": "Contact form" } } // rich text — same array shape as title, 2000 char ceiling "Message": { "rich_text": [ { "text": { "content": "Hello there" } } ] }
/ The request
What does the create-page request look like?
The parent identifies where the page lands. Current API versions accept data_source_id for a database-backed page, and still accept database_id and page_id parents.¹ Send whichever your integration was set up against, and pin the version header so a future API release cannot change the shape underneath you.
PHP — creating the page
function my_send_to_notion( array $properties ) { $response = wp_remote_post( 'https://api.notion.com/v1/pages', [ 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . MY_NOTION_TOKEN, 'Notion-Version' => '2026-03-11', // required, pin it 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'parent' => [ 'data_source_id' => MY_NOTION_SOURCE ], 'properties' => $properties, ] ), ] ); if ( is_wp_error( $response ) ) { return $response; } return wp_remote_retrieve_response_code( $response ); }
/ Mapping
How do you wrap each value in its property type?
Keep the type next to the column name in one table, so the mapper is data rather than a pile of conditionals. Truncate as you go — the 2,000-character ceiling on a rich-text value is a real limit that a long message field will find.
PHP — a typed mapper
$map = [ // form tag column Notion type 'your-name' => [ 'Name', 'title' ], 'your-email' => [ 'Email', 'email' ], 'your-subject' => [ 'Subject', 'select' ], 'your-message' => [ 'Message', 'rich_text' ], ]; $props = []; foreach ( $map as $tag => list( $column, $type ) ) { if ( empty( $data[ $tag ] ) ) { continue; // never send null — omit the property instead } $v = is_array( $data[ $tag ] ) ? implode( ', ', $data[ $tag ] ) : (string) $data[ $tag ]; $v = mb_substr( $v, 0, 2000 ); // hard API ceiling $props[ $column ] = match ( $type ) { 'title' => [ 'title' => [ [ 'text' => [ 'content' => $v ] ] ] ], 'rich_text' => [ 'rich_text' => [ [ 'text' => [ 'content' => $v ] ] ] ], 'email' => [ 'email' => $v ], 'select' => [ 'select' => [ 'name' => $v ] ], }; }
Omitting an empty property is deliberate. Notion treats a missing key as "leave it blank", but an explicit null in the wrong position is a validation error — skipping is both safer and shorter.
/ Limits
What are Notion's rate and size limits?
| Limit | Value | What happens at the boundary |
|---|---|---|
| Requests per second, per connection | ~3 average, bursts allowed | 429 with a Retry-After header |
| Rich text / URL value | 2,000 characters | Request rejected |
| Email value | 200 characters | Request rejected |
| Blocks per request | 1,000 | Request rejected |
| Request payload | 500KB | Request rejected |
Notion documents the per-connection limit as "an average of three requests per second, with some bursts beyond the average allowed", and a workspace-wide limit that scales with the plan.² Three per second is 180 a minute — comfortable for form traffic, tight for a bulk import. A 500-entry backfill at one page per request is 500 ÷ 3 ≈ 167 seconds of continuous traffic, and there is no batch-create endpoint to shorten it.
Notion's rate limit is an average with burst tolerance, not a hard gate — which means a bulk import appears to work for the first few seconds and then starts failing.
/ Gaps
What does Notion not protect you from?
- Schema changes break you silently. Rename a column in the Notion UI and every later request 400s. Nothing warns WordPress, and the person who renamed it has no idea an integration existed.
- No deduplication. Post the same submission twice and you get two pages. There is no idempotency key — check for an existing page first if double submits are possible.
- System properties are read-only.
created_time,created_by,last_edited_time,last_edited_byandrollupcannot be set through the API, so a "submitted at" column has to be one you create yourself.¹ - The integration must be invited. A valid token still returns "object not found" until the data source is explicitly shared with the integration — the most common first-run failure, and it does not look like a permissions error.
/ Delivery
Should this run inline or on a queue?
On a queue, for the same reason as any third-party call inside a form submission: the visitor should never wait on Notion, and a failed request must not be the end of the entry. An inline call inside wpcf7_mail_sent has exactly one attempt and keeps the only copy of the data in memory.
| Concern | Inline call | Queued delivery |
|---|---|---|
| Visitor wait | Blocks on Notion | Returns immediately |
| Retry-After | Ignored — single attempt | Honoured, entry preserved |
| Schema drift | Silent 400, entry gone | Failed attempt kept and visible |
| Backfill | Trips the 3/s average | Paced by the queue |
/ Retries
How do you handle 429 and Retry-After?
Read the header. Notion returns 429 with a Retry-After value in seconds, and 529 when the service is overloaded — both are worth retrying, and both tell you when.² A fixed one-second backoff against an average-based limiter tends to make things worse, because you rejoin the traffic exactly when the average is still over.
Validation errors are different: a 400 from a property-type mismatch will never succeed on retry. Separate the two classes before you write the loop — the reasoning is the same one behind a capped exponential backoff, and the same distinction we make in the Airtable version of this integration.
Try it without writing the plumbing. Open the live preview — a full WordPress with Webhook Actions already set up, nothing to install — or install the free plugin and map the fields in an admin screen.
Retry-After, and the 2,000-character and 500KB size caps: Notion request limits.wpcf7_mail_sent signature and get_posted_data() verified in includes/submission.php of Contact Form 7 6.1.6. First-party documentation: contactform7.com/docs.