Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The short answer: an @InvocableMethod receives a list, and the list is how Flow bulkifies. Writing one that takes a list, processes element zero, and returns a single result works perfectly in a screen flow and fails silently the first time a record-triggered flow passes it 200 records.

A bulk-safe invocable methodThe compiler enforces four rules about the signature. The one that matters it does not enforce.01Flow calls the method once, with a list — that list is the bulkification02Collect every Id from the requests first, before any query03One query into a Map for the whole list04Loop the requests and build results in input order05Return exactly as many results as you received: Flow maps result n toinput n06Return fewer and the remaining records get nothing, with no error at allAn action designed for bulk but invoked inside a Flow loop is called once per iteration, andno Apex can fix that. The fix is in the flow.
The signature rule the compiler does not enforce, and what it costs.

The signature is the contract

public with sharing class CreditCheckAction {

    public class Request {
        @InvocableVariable(required=true) public Id accountId;
        @InvocableVariable public Decimal amount;
    }

    public class Result {
        @InvocableVariable public Boolean approved;
        @InvocableVariable public String reason;
    }

    @InvocableMethod(label='Run Credit Check' category='Finance')
    public static List<Result> run(List<Request> requests) {
        ...
    }
}

Four rules the compiler enforces and one it does not. It must be static, there can be only one per class, it takes exactly one parameter which is a list, and it returns a list or nothing. The rule it does not enforce is the important one: the returned list must have the same number of elements as the input, in the same order. Flow maps result n back to input n. Return one element for a 200-element input and 199 records get nothing, with no error.

What bulk-safe actually means here

Collect everything first, do the work in sets, then build results in input order:

@InvocableMethod(label='Run Credit Check')
public static List<Result> run(List<Request> requests) {
    Set<Id> accountIds = new Set<Id>();
    for (Request r : requests) { accountIds.add(r.accountId); }

    Map<Id, Account> accounts = new Map<Id, Account>([
        SELECT Id, Credit_Limit__c, Outstanding__c
        FROM Account WHERE Id IN :accountIds
    ]);

    List<Result> out = new List<Result>();
    for (Request r : requests) {
        Account a = accounts.get(r.accountId);
        Result res = new Result();
        res.approved = a != null && (a.Credit_Limit__c - a.Outstanding__c) >= r.amount;
        res.reason   = a == null ? 'Account not found'
                     : res.approved ? 'Within limit' : 'Exceeds available credit';
        out.add(res);
    }
    return out;
}

One query for any number of requests, and a result for every input including the ones that failed. Returning a result with a reason beats throwing, because a thrown exception fails the whole flow interview rather than the one record that had a problem.

The custom-type wrapper is worth the extra code

You can declare primitive parameters instead — List<Id> in, List<Boolean> out, and it is tempting for a simple action. It stops being simple the first time you need a second input, because adding one means a new method and a new action, and every flow using the old one has to be edited.

A wrapper class with @InvocableVariable fields lets you add optional inputs without breaking anything already built. Mark only genuinely required things required=true, and give each variable a label and description, because those are what an admin sees in Flow Builder and the default is your field name.

Where the limits land

The invocable method runs inside the flow’s transaction and shares its limits. A record-triggered flow on 200 records calls your method once with 200 requests, so one query is one query. But a flow that calls the action inside a loop calls the method once per iteration, and there is nothing your Apex can do about that. The fix is in the flow.

That is worth telling whoever builds the flow. An action designed for bulk, invoked in a loop, hits the 101 query limit at exactly the same point as any other query in a loop.

Callouts need the annotation

@InvocableMethod(callout=true) tells the platform this action makes a callout. Without it, a flow that has already done DML fails with uncommitted work pending. With it, Flow can arrange the transaction correctly, and in some cases will not let the action run where a callout is impossible.

Frequently asked

Can it be @AuraEnabled too? Not on the same method. Write a thin invocable wrapper around a shared service class. That is the right structure anyway.

Why does my action not appear in Flow Builder? Usually the class is not global or public, the method is not static, or the org has not finished compiling. Check the category and label too. It may be listed somewhere you did not look.

Can I return nothing? Yes, void is allowed and the action simply has no output. Useful for fire-and-forget work, and it removes the ordering concern entirely.