WP Webhooks / Blog / WordPress integrations
Article · WordPress integrations

Turning Contact Form 7 Submissions Into Notion Pages

Send Contact Form 7 entries to Notion: create a page per submission with typed properties, the Notion-Version header, rate limits and the 2,000-char cap.

8 min 2026-08-18
#contact-form-7#notion#integrations

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: Bearer and Notion-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-After header 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.

FIG 01 — A form entry becoming a typed Notion page

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?

LimitValueWhat happens at the boundary
Requests per second, per connection~3 average, bursts allowed429 with a Retry-After header
Rich text / URL value2,000 charactersRequest rejected
Email value200 charactersRequest rejected
Blocks per request1,000Request rejected
Request payload500KBRequest 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_by and rollup cannot 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.

ConcernInline callQueued delivery
Visitor waitBlocks on NotionReturns immediately
Retry-AfterIgnored — single attemptHonoured, entry preserved
Schema driftSilent 400, entry goneFailed attempt kept and visible
BackfillTrips the 3/s averagePaced 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.

/Footnotes
¹ Endpoint, required headers, parent shapes and the read-only system properties: Notion create a page.
² Three requests per second average, 429 with 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.
WordPress HTTP helpers: wp_remote_post(), wp_json_encode().
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

How do you add a row to a Notion database from WordPress? +
You create a page. In Notion a database row is a page, so the call is POST https://api.notion.com/v1/pages with the parent set to your data source or database ID. There is no separate rows endpoint.
Why does the Notion API reject my form values? +
Because Notion requires typed property values, not flat strings. A title must be an array of rich-text objects, a select must be an object with a name key, and an email is a plain string. Sending a bare string where the schema declares a title returns a validation_error and creates nothing.
Is the Notion-Version header required? +
Yes. Every request must carry Notion-Version alongside the Authorization bearer token. Pin a specific version rather than tracking the newest, so a future API release cannot change the request shape underneath a working integration.
What is the Notion API rate limit? +
An average of about three requests per second per connection, with some bursts allowed, plus a workspace-wide limit that scales with the plan. Exceeding it returns HTTP 429 with a Retry-After header giving the number of seconds to wait.
Why does Notion say object not found when my token is valid? +
The integration has not been given access to that database. A token is valid workspace-wide but sees only what has been explicitly shared with it, so the data source must be connected to the integration before any page can be created in it.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate