Legacy / v1 API
Reference for the legacy aRPC gRPC API: one bidirectional Subscribe RPC streaming decoded Solana transactions and observed slots.
The legacy API is one bidirectional RPC. Your transaction filters go up the same stream that decoded transactions, observed slots and ping responses come back down.
Proto: Download service.proto (package arpc, service ARPCService). You'll see this same shape called "v1" in some integrations.
service ARPCService {
// Subscribe to decoded transactions
rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse) {}
}Connect to http://arpc.<region>.corvus-labs.io:20202 with insecure channel credentials; all nine regions behave the same way. You get in on an allowlisted egress IP by default; if your dedicated deployment is token-authenticated, attach the token as request metadata:
x-token: YOUR_API_TOKENDecoded transactions and slot numbers are the whole surface here. v2 adds raw transaction bytes, an entry stream, a dedicated slot stream with status, per-filter register/unregister, acknowledged filter validation, resolved lookup-table addresses and a sequence counter.
Request messages
message SubscribeRequest {
map<string, SubscribeRequestFilterTransactions> transactions = 1;
optional int32 ping_id = 2;
optional bool new_seen_slots = 3;
}
message SubscribeRequestFilterTransactions {
repeated string account_include = 2;
repeated string account_exclude = 3;
repeated string account_required = 4;
}Watch the field numbers on the filter message: they start at 2, not at 1. This is the detail hand-rolled encoders get wrong.
Each map key is a filter name you choose; the values are Base58 Solana public keys.
stream.write({
transactions: {
'token-program': {
account_include: [
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
],
account_exclude: [],
account_required: [],
},
},
new_seen_slots: true,
});Matching rules
Inside one named filter, all three conditions apply together:
| Field | Match rule |
|---|---|
account_include | At least one listed account must be present |
account_required | Every listed account must be present |
account_exclude | No listed account may be present |
- No include and no required accounts → broad match, minus anything excluded.
- An entirely empty filter → every transaction.
- When a transaction matches several named filters,
SubscribeResponse.filterslists every matching name.
Updating filters
Every update carries the complete map you want, not a patch: each non-empty map replaces the previous one wholesale. An empty map won't clear anything either: it reads as "no change", and every previous filter stays active. If you want to end up with no filters at all, you need a new stream.
There is no unregister message here and no FilterValidationResult, so nothing ever acknowledges a filter change. An invalid Base58 key comes back as gRPC INVALID_ARGUMENT (3) and ends the stream, so validate keys on your side first.
Push past 50 filter-update messages per second and the stream is rejected outright with gRPC RESOURCE_EXHAUSTED (8), reason subscribe_rate_limit; see limits.
new_seen_slots is independent of the map, and leaving it out keeps whatever it was set to.
Response messages
message SubscribeResponse {
google.protobuf.Timestamp created_at = 1;
repeated string filters = 2;
optional SubscribeResponseTransaction transaction = 3;
optional int32 ping_id = 4;
optional uint64 new_slot = 5;
}transaction, ping_id and new_slot are optional fields rather than a oneof, so you branch on whichever one is actually present in a given response.
message SubscribeResponseTransaction {
uint64 slot = 1;
uint32 num_required_signatures = 2;
uint32 num_readonly_signed_accounts = 3;
uint32 num_readonly_unsigned_accounts = 4;
bytes recent_blockhash = 5;
repeated bytes signatures = 6;
repeated bytes account_keys = 7;
repeated CompiledInstruction instructions = 8;
repeated MessageAddressTableLookup address_table_lookups = 9;
google.protobuf.BoolValue versioned = 10;
}
message MessageAddressTableLookup {
bytes account_key = 1;
bytes writable_indexes = 2;
bytes readonly_indexes = 3;
}
message CompiledInstruction {
uint32 program_id_index = 1;
bytes accounts = 2;
bytes data = 3;
}- There is no
transaction.signaturefield. Your primary signature issignatures[0]. - Signatures,
account_keys,recent_blockhashand the lookup-table fields are all raw bytes, not Base58 strings. address_table_lookupscarries the table and the indexes, not the resolved addresses. v2'sloaded_writable_addresses/loaded_readonly_addressescarry the resolved ones.versionedis aBoolValuewrapper, so it can be absent as well as true or false.- What you have is a decode of observed network data, not an execution receipt: no fee, no logs, no status.
stream.on('data', (response) => {
if (!response.transaction) return;
const tx = response.transaction;
console.log({
slot: tx.slot,
matchedFilters: response.filters,
primarySignature: tx.signatures?.[0]?.toString('hex'),
});
});Observe slots
Set new_seen_slots: true and the same stream delivers new_slot values.
if (response.new_slot !== undefined) {
console.log('slot observed', response.new_slot);
}new_slot is a slot aRPC has started seeing, and that is all it tells you: no status, no commitment, and no legacy equivalent of v2's SLOT_STATUS_DEAD / SLOT_STATUS_COMPLETE.
Ping
stream.write({ ping_id: 42 });if (response.ping_id === 42) {
console.log('pong');
}Not every response ping_id corresponds to a ping you sent: the server also emits an unsolicited heartbeat every 10 seconds on this stream, a SubscribeResponse whose only populated field is ping_id = 0. Match by value when you care which pong you have. See limits.
Errors and reconnects
| Signal | Meaning | Action |
|---|---|---|
UNAUTHENTICATED (16) | A token endpoint rejected x-token | Fix the credential; retrying unchanged fails identically |
PERMISSION_DENIED (7), detail starting PPS limit requirement not met | The PPS condition on the streaming endpoint; the server then shuts the connection down | See limits; reconnecting alone does not clear it |
INVALID_ARGUMENT (3) | Invalid public key in a filter | Fix the filter, then open a new stream |
| RPC fails immediately after connect | Egress IP is not allowlisted | Register the real public egress address |
There is no resume and no replay, so every new stream starts from nothing: connect from an allowlisted IP, re-attach x-token if you use one, send the complete filter map again, restore new_seen_slots, recreate your ping timers, and reconcile the gap through Solana RPC if completeness matters.
Pre-execution data
Transactions on this stream haven't executed yet. Confirm by signature through Solana RPC before you do anything irreversible.