ConvertKit - v1.0.0
React Subscribe Form
Client-driven subscribe form that POSTs to /convertkit/subscribers.
Fetch
Zero-dependency version. Copy into any React 18+ codebase.
import { useState } from 'react';
const CSRF_TOKEN =
document.querySelector( 'meta[name="csrf-token"]' )?.content ?? '';
export function SubscribeForm( { feedId } ) {
const [ email, setEmail ] = useState( '' );
const [ status, setStatus ] = useState( 'idle' );
const [ error, setError ] = useState( null );
async function submit( event ) {
event.preventDefault();
setStatus( 'sending' );
setError( null );
try {
const response = await fetch( '/convertkit/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
},
body: JSON.stringify( { feed_id: feedId, email } ),
} );
if ( response.status === 202 ) {
setStatus( 'done' );
return;
}
if ( response.status === 429 ) {
setError( 'Too many attempts — please try again in a minute.' );
setStatus( 'idle' );
return;
}
const body = await response.json().catch( () => ( {} ) );
setError( body.message ?? 'Something went wrong.' );
setStatus( 'idle' );
} catch ( err ) {
setError( 'Network error. Please try again.' );
setStatus( 'idle' );
}
}
if ( status === 'done' ) {
return <p role="status">Thanks — check your inbox to confirm.</p>;
}
return (
<form onSubmit={ submit } style={ { display: 'flex', gap: '.5rem' } }>
<input
type="email"
required
placeholder="you@example.com"
value={ email }
onChange={ ( e ) => setEmail( e.target.value ) }
disabled={ status === 'sending' }
/>
<button type="submit" disabled={ status === 'sending' }>
{ status === 'sending' ? 'Sending…' : 'Subscribe' }
</button>
{ error && <p role="alert">{ error }</p> }
</form>
);
}
Usage:
<SubscribeForm feedId={ 1 } />
Axios
If you're already using axios and its interceptors handle CSRF for you (Laravel Breeze / Jetstream defaults), the request is a one-liner:
import axios from 'axios';
import { useState } from 'react';
export function SubscribeForm( { feedId } ) {
const [ email, setEmail ] = useState( '' );
const [ status, setStatus ] = useState( 'idle' );
const [ error, setError ] = useState( null );
async function submit( event ) {
event.preventDefault();
setStatus( 'sending' );
setError( null );
try {
await axios.post( '/convertkit/subscribers', { feed_id: feedId, email } );
setStatus( 'done' );
} catch ( err ) {
if ( err.response?.status === 429 ) {
setError( 'Too many attempts — please try again in a minute.' );
} else {
setError( err.response?.data?.message ?? 'Something went wrong.' );
}
setStatus( 'idle' );
}
}
// …markup identical to the fetch example
}
With kit_form_id instead
If you don't want the feed indirection:
body: JSON.stringify( {
kit_form_id: 12345,
email,
first_name: firstName,
fields: { company },
tags: [ 100 ],
} ),
Kit form ids are essentially public — embedding one in client-side JS is not a security concern.
Validation errors
The endpoint returns 422 with Laravel's standard shape:
{
"message": "The email field must be a valid email address.",
"errors": { "email": [ "The email field must be a valid email address." ] }
}
Handle it by iterating errors and showing the first message per field:
if ( response.status === 422 ) {
const body = await response.json();
setFieldErrors( body.errors ?? {} );
setStatus( 'idle' );
return;
}
CORS
If the SPA lives on a different origin from the Laravel app, add the subscribe endpoint's origin to config/cors.php, or add HandleCors to convertkit.subscribe.middleware in config/convertkit.php.
Testing
Component-level: mock fetch (or axios) and assert the request shape.
End-to-end: use Playwright or Cypress against a real Laravel dev server with FakeConvertKit installed for the whole test run.