ConvertKit - v1.0.0

Vue Subscribe Form

Client-driven subscribe form that POSTs to /convertkit/subscribers. Vue 3, Composition API, <script setup>.

Fetch

Zero-dependency version.

<script setup>
import { ref } from 'vue';

const props = defineProps( { feedId: { type: Number, required: true } } );

const CSRF_TOKEN =
    document.querySelector( 'meta[name="csrf-token"]' )?.content ?? '';

const email  = ref( '' );
const status = ref( 'idle' );
const error  = ref( null );

async function submit() {
    status.value = 'sending';
    error.value  = 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: props.feedId, email: email.value } ),
        } );

        if ( response.status === 202 ) {
            status.value = 'done';
            return;
        }

        if ( response.status === 429 ) {
            error.value  = 'Too many attempts — please try again in a minute.';
            status.value = 'idle';
            return;
        }

        const body = await response.json().catch( () => ( {} ) );
        error.value  = body.message ?? 'Something went wrong.';
        status.value = 'idle';
    } catch {
        error.value  = 'Network error. Please try again.';
        status.value = 'idle';
    }
}
</script>

<template>
    <p v-if="status === 'done'" role="status">
        Thanks — check your inbox to confirm.
    </p>

    <form v-else @submit.prevent="submit" style="display: flex; gap: .5rem">
        <input
            v-model="email"
            type="email"
            placeholder="you@example.com"
            required
            :disabled="status === 'sending'"
        />
        <button type="submit" :disabled="status === 'sending'">
            {{ status === 'sending' ? 'Sending…' : 'Subscribe' }}
        </button>
    </form>

    <p v-if="error" role="alert">{{ error }}</p>
</template>

Usage:

<SubscribeForm :feed-id="1" />

Axios

If axios interceptors already handle CSRF for you (Breeze / Jetstream defaults):

<script setup>
import axios from 'axios';
import { ref } from 'vue';

const props = defineProps( { feedId: { type: Number, required: true } } );

const email  = ref( '' );
const status = ref( 'idle' );
const error  = ref( null );

async function submit() {
    status.value = 'sending';
    error.value  = null;

    try {
        await axios.post( '/convertkit/subscribers', {
            feed_id: props.feedId,
            email:   email.value,
        } );
        status.value = 'done';
    } catch ( err ) {
        if ( err.response?.status === 429 ) {
            error.value = 'Too many attempts — please try again in a minute.';
        } else {
            error.value = err.response?.data?.message ?? 'Something went wrong.';
        }
        status.value = 'idle';
    }
}
</script>

Template same as the fetch version.

With kit_form_id instead

Skip the feed indirection:

body: JSON.stringify( {
    kit_form_id: 12345,
    email: email.value,
    first_name: firstName.value,
    fields: { company: company.value },
    tags: [ 100 ],
} ),

Validation errors

Same shape as the React recipe — Laravel returns 422 with { message, errors }. Handle by mapping errors to per-field state:

if ( response.status === 422 ) {
    const body = await response.json();
    fieldErrors.value = body.errors ?? {};
    status.value = 'idle';
    return;
}

Emit success

If the form is a child of a parent that owns the success UI:

<script setup>
const emit = defineEmits( [ 'subscribed' ] );

// …inside the 202 branch:
emit( 'subscribed', email.value );
</script>

CORS

If the Vue SPA lives on a different origin from Laravel, add the subscribe endpoint's origin to config/cors.php, or add HandleCors to convertkit.subscribe.middleware.

Testing

Component-level: Vitest + Vue Test Utils, mock fetch (or axios) and assert the request shape.

End-to-end: Playwright or Cypress with FakeConvertKit installed on the Laravel side for the whole test run.