---
title: "Contact Form 7 to Notion: Create a Page From Each Entry"
description: "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."
url: "https://wpwebhooks.org/blog/contact-form-7-to-notion/"
date: "2026-08-18"
---

# Contact Form 7 to Notion: Create a Page From Each Entry

**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": "ada@example.com" }

// 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_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.

| 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](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/), and the same distinction we make in the [Airtable version of this integration](https://wpwebhooks.org/blog/contact-form-7-to-airtable/).

**Try it without writing the plumbing.** [Open the live preview](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) — a full WordPress with Webhook Actions already set up, nothing to install — or [install the free plugin](https://wordpress.org/plugins/flowsystems-webhook-actions/) and map the fields in an admin screen.

/Footnotes

¹ Endpoint, required headers, parent shapes and the read-only system properties: [Notion create a page](https://developers.notion.com/reference/post-page).

² Three requests per second average, 429 with `Retry-After`, and the 2,000-character and 500KB size caps: [Notion request limits](https://developers.notion.com/reference/request-limits).

³ `wpcf7_mail_sent` signature and `get_posted_data()` verified in `includes/submission.php` of [Contact Form 7](https://wordpress.org/plugins/contact-form-7/) 6.1.6. First-party documentation: [contactform7.com/docs](https://contactform7.com/docs/).

⁴ WordPress HTTP helpers: [wp\_remote\_post()](https://developer.wordpress.org/reference/functions/wp_remote_post/), [wp\_json\_encode()](https://developer.wordpress.org/reference/functions/wp_json_encode/).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"Contact Form 7 to Notion: Create a Page From Each Entry","description":"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.","datePublished":"2026-08-18","dateModified":"2026-08-18","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/contact-form-7-to-notion/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["cf7 notion","contact form 7 notion","contact form 7 to notion","notion api wordpress","wordpress notion integration"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Contact Form 7 to Notion: Create a Page From Each Entry","item":"https://wpwebhooks.org/blog/contact-form-7-to-notion/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do you add a row to a Notion database from WordPress?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why does the Notion API reject my form values?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Is the Notion-Version header required?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What is the Notion API rate limit?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why does Notion say object not found when my token is valid?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/contact-form-7-to-notion.png","caption":"FIG 01 — A form entry becoming a typed Notion page","description":"The same wpcf7_mail_sent handler reads the posted data, but Notion will not accept flat strings. Every value must be wrapped in the property type its schema declares: a title becomes an array of rich text objects, an email becomes an email property, a select becomes a named option. The request goes to the create-page endpoint carrying both a bearer token and a Notion-Version header, and names the parent data source. Notion allows an average of three requests per second per connection and returns 429 with a Retry-After header in seconds. Rich text is capped at two thousand characters per value, so long message fields must be truncated or split before sending.","encodingFormat":"image/png","creditText":"WP Webhooks","license":"https://wpwebhooks.org/"}
```
