I've been building a YouTube Shorts automation engine as a side project — the kind of system that takes a niche, generates a script, hands it to a text-to-speech API, pairs it with stock footage, and uploads it automatically.
The pipeline itself is a bigger build. But one thing kept slowing me down at the start of every session: I'd sit down to test a new part of the system and realise I needed a decent script to feed into it. Writing one from scratch every time was killing momentum.
So I built a small tool to solve that problem. A few hours later I had something I actually wanted to use — and apparently other people did too.
Here's how I built it.
What the tool does
You give it a niche and a topic. It gives you back a complete YouTube Shorts brief:
- Hook — one punchy line, max 12 words, engineered to stop the scroll
- Script — 45–60 seconds spoken, conversational, with a built-in insight and soft CTA
- B-roll notes — 4 specific visual suggestions matched beat-for-beat to the script
- Caption — under 150 characters, copy-paste ready
- Hashtags — 8 tags, mix of high-volume and niche-specific
The whole thing runs on PHP, calls the OpenAI API server-side, and sits behind a Stripe credit system.
The stack
- PHP 8.x (already on my shared hosting)
- Bootstrap 5 for the frontend
- OpenAI
gpt-4o-mini— cheap, fast, good enough - Stripe Payment Links + webhooks
- PHPMailer for transactional email
- MySQL for user and credit storage
Nothing exotic. Deliberately so — I wanted something I could deploy to shared hosting without touching a terminal.
The prompt engineering
This is where most of the actual work happened. Getting the model to return clean, consistently structured output without preamble or markdown took more iteration than the code did.
The final system prompt:
You are an expert YouTube Shorts scriptwriter.
Your job is to generate a complete, ready-to-publish YouTube Shorts brief.
Return ONLY the following structure, with NO preamble, no commentary, no markdown formatting:
🪝 HOOK
[One punchy spoken line that stops the scroll. Use curiosity, shock, or a bold claim. Max 12 words.]
📝 SCRIPT
[Natural, conversational script for 45-60 seconds spoken. No jargon. Short sentences.
Each line flows into the next. Include one surprising fact or insight.
End with a soft CTA like "follow for more" or "comment below."]
🎬 B-ROLL
[4 specific visual suggestions that match the script beat by beat. Be precise —
not "show a phone" but "close-up of ChatGPT interface generating a response in real time."]
📣 CAPTION
[One punchy caption under 150 characters with a hook and 2-3 relevant emojis.]
#️⃣ HASHTAGS
[8 hashtags — mix of high volume and niche specific. One per line. No spaces between words.]
The key things that made it work:
- "Return ONLY" — without this you get preamble every time
- Emoji section headers — made parsing reliable and consistent
- Negative examples in the B-roll instruction — telling it what not to do ("not 'show a phone' but...") produced dramatically more specific output
- Word limits on the hook — "max 12 words" was enforced much better than "short"
The PHP backend
The frontend posts to generate.php, which handles the OpenAI call server-side so the API key never touches the browser:
$payload = json_encode([
'model' => 'gpt-4o-mini',
'max_tokens' => 1000,
'messages' => [
['role' => 'system', 'content' => $system_prompt],
['role' => 'user', 'content' => "Niche: {$niche}\nTopic: {$topic}\nTone: {$tone}"],
]
]);
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $api_key,
],
CURLOPT_TIMEOUT => 30,
]);
Standard stuff. The response parsing just pulls $decoded['choices'][0]['message']['content'] and sends it back as JSON.
Parsing the output on the frontend
Since the model returns consistent emoji-headed sections, parsing is straightforward:
function extract(text, startMarker, endMarker) {
const start = text.indexOf(startMarker);
if (start === -1) return '';
const afterMarker = text.indexOf('\n', start) + 1;
const end = endMarker ? text.indexOf(endMarker, afterMarker) : text.length;
return text.slice(afterMarker, end === -1 ? text.length : end).trim();
}
const sections = {
hook: extract(text, '🪝 HOOK', '📝 SCRIPT'),
script: extract(text, '📝 SCRIPT', '🎬 B-ROLL'),
broll: extract(text, '🎬 B-ROLL', '📣 CAPTION'),
caption: extract(text, '📣 CAPTION', '#️⃣ HASHTAGS'),
hashtags: extract(text, '#️⃣ HASHTAGS', null),
};
Each section renders into its own panel with a copy button.
The credit system
Initially I thought about selling lifetime access — £27 once, use it forever. Then I did the maths. gpt-4o-mini costs roughly £0.01 per generation. A heavy user could generate 1,000 briefs and cost me £10 on a one-time payment. Not sustainable.
Credit packs made more sense:
| Pack | Credits | Price |
|---|---|---|
| Starter | 30 | £7 |
| Creator | 100 | £19 |
| Operator | 300 | £47 |
Credits never expire. Users get an email alert when they drop to 10 remaining. Top-ups add to their existing balance via the same access link — no new account, no friction.
The MySQL table is simple:
CREATE TABLE `users` (
`token` CHAR(64) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`credits` INT UNSIGNED NOT NULL DEFAULT 0,
`credits_used` INT UNSIGNED NOT NULL DEFAULT 0,
`low_credit_sent` TINYINT(1) NOT NULL DEFAULT 0,
UNIQUE KEY `token` (`token`),
KEY `email` (`email`)
);
The Stripe webhook gotcha
Stripe occasionally fires duplicate webhook events — either retries or genuine duplicates. The first version of the webhook used payment_intent as the idempotency key, which didn't catch all cases.
The fix was to use $session['id'] — the checkout session ID — which is always the same on retries, and back it up with a separate payments table with a unique constraint:
// Try to insert — unique constraint rejects duplicates
try {
$pdo->prepare('
INSERT INTO payments (payment_intent, email, pack, credits_added)
VALUES (?, ?, ?, ?)
')->execute([$session_id, $buyer_email, $pack_key, $credits_to_add]);
} catch (\PDOException $e) {
// Already processed — return 200 silently
http_response_code(200);
exit;
}
The whole credit update runs inside a transaction. If two requests race past the initial check simultaneously
, only one can insert into payments. The other hits the unique constraint and exits without touching credits.
What I'd do differently
Use a queue for webhook processing. On shared hosting, if the webhook takes more than a few seconds Stripe retries it. A job queue would let you return 200 immediately and process in the background — cleaner than relying purely on idempotency keys.
Add usage analytics from day one. I can see total credits used per user but I have no visibility into which niches or topics are generating the most briefs. That data would be useful for marketing.
Test the email earlier. PHP's mail() is unreliable on shared hosting. I switched to PHPMailer with SMTP on day two — should have started there.
The result
The tool is live at https://creator.buizy.io
If you're building a faceless YouTube channel or a Shorts automation pipeline and want to stop writing briefs by hand, it's £7 to try.
If you're a developer building something similar, hopefully the prompt structure and idempotency approach save you a few hours of head-scratching.
Happy to answer questions in the comments.
Built with PHP, OpenAI, Stripe, and too much coffee.


Top comments (0)