Salesforce Integration
Connect Salesforce to Certyo using Platform Events, Apex callouts, and custom objects. Automatically anchor order fulfillment data to the blockchain and track certificate status directly in Salesforce.
Prerequisites
- Salesforce Enterprise or Unlimited edition (Platform Events and Named Credentials require these editions)
- A Certyo API key — see the Authentication Guide
- System Administrator or a custom permission set with access to Apex classes, Platform Events, and Named Credentials
- My Domain enabled (required for Named Credentials)
Architecture
┌──────────────────────────────────┐
│ Salesforce Org │
│ │
│ Order status -> "Fulfilled" │
│ │ │
│ v │
│ Apex Trigger (OrderTrigger) │
│ │ │
│ v │
│ Platform Event │
│ (Certyo_Record_Ingest__e) │
│ │ │
│ v │
│ Apex Trigger (Platform Event) │
│ -> @future(callout=true) │
│ │ │
│ v │
│ CertyoService.ingestRecord() │──────────┐
│ (Named Credential callout) │ │
│ │ │ │ HTTPS POST
│ v │ │ X-API-Key
│ Certificate__c record │ │
│ (AnchorStatus = Pending) │ v
│ │ ┌──────────────────┐
│ Scheduled Apex (every 15 min) │ │ Certyo API │
│ -> CertyoService.verifyRecord() │ │ POST /api/v1/ │
│ -> Certificate__c updated │ │ records │
│ (AnchorStatus = Anchored) │ │ │
└──────────────────────────────────┘ │ 202 Accepted │
└────────┬─────────┘
│
v
┌──────────────────┐
│ Pipeline │
│ Kafka -> │
│ Accumulate -> │
│ Merkle Tree -> │
│ IPFS -> Polygon │
└──────────────────┘Custom Object: Certificate__c
Create a custom object to track the blockchain certificate lifecycle for each record. Navigate to Setup > Object Manager > Create > Custom Object.
| Field API Name | Type | Description |
|---|---|---|
RecordId__c | Text(255) | The Certyo recordId (e.g. the Order number) |
RecordHash__c | Text(255) | SHA-256 hash returned by Certyo on ingestion |
SnapshotId__c | Text(255) | Certyo snapshot ID containing this record |
MerkleRoot__c | Text(255) | Merkle tree root hash for the anchoring batch |
AnchorStatus__c | Picklist | Pending | Batched | Anchored | Failed |
PolygonTxHash__c | Text(255) | Polygon transaction hash once anchored on-chain |
IpfsCid__c | Text(255) | IPFS content identifier for the manifest |
VerifiedAt__c | DateTime | Timestamp of last successful verification |
Order__c | Lookup(Order) | Related Order record for navigation and reporting |
Named Credential
Store the Certyo API endpoint and authentication header in a Named Credential so Apex code never handles raw secrets. Navigate to Setup > Named Credentials > New Named Credential.
Label: Certyo API
Name: Certyo_API
URL: https://www.certyos.com
Identity Type: Named Principal
Authentication: Custom Header
Header Name: X-API-Key
Header Value: certyo_sk_live_your_key_here
Generate Auth Header: unchecked
Allow Merge Fields: checkedImplementation
Core service class that handles both record ingestion and verification. Uses the Named Credential for authentication so no API keys appear in code.
/**
* CertyoService — Apex class for Certyo API integration.
* Uses Named Credential 'Certyo_API' for authentication.
*
* Methods:
* ingestRecord() — POST /api/v1/records (async via @future)
* verifyRecord() — POST /api/v1/verify/record
* queryRecord() — GET /api/v1/records?tenantId=X&recordId=Y
*/
public with sharing class CertyoService {
private static final String NAMED_CREDENTIAL = 'callout:Certyo_API';
private static final String INGEST_PATH = '/api/v1/records';
private static final String VERIFY_PATH = '/api/v1/verify/record';
private static final String QUERY_PATH = '/api/v1/records';
// Tenant ID — configure via Custom Metadata Type or Custom Setting
private static String getTenantId() {
Certyo_Settings__c settings = Certyo_Settings__c.getOrgDefaults();
return settings.Tenant_Id__c != null
? settings.Tenant_Id__c
: 'default';
}
/**
* Ingest a record asynchronously.
* Called via @future so it runs outside the trigger transaction.
*
* @param orderId The Order record ID (Salesforce ID)
* @param orderNumber The Order number (used as Certyo recordId)
* @param payloadJson Serialized JSON of the order data
*/
@future(callout=true)
public static void ingestRecord(
Id orderId,
String orderNumber,
String payloadJson
) {
String tenantId = getTenantId();
String today = String.valueOf(Date.today());
Map<String, Object> body = new Map<String, Object>{
'tenantId' => tenantId,
'database' => 'salesforce',
'collection' => 'orders',
'recordId' => orderNumber,
'recordVersion' => '1',
'operationType' => 'insert',
'recordPayload' => (Map<String, Object>) JSON.deserializeUntyped(payloadJson),
'sourceTimestamp' => Datetime.now().formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
'idempotencyKey' => orderNumber + '-v1-' + today
};
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + INGEST_PATH);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(body));
req.setTimeout(30000);
Http http = new Http();
HttpResponse res = http.send(req);
Certificate__c cert = new Certificate__c(
Order__c = orderId,
RecordId__c = orderNumber,
AnchorStatus__c = 'Pending'
);
if (res.getStatusCode() == 202) {
Map<String, Object> result =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
cert.RecordHash__c = (String) result.get('recordHash');
System.debug('Certyo ingestion accepted: ' + orderNumber +
' hash=' + cert.RecordHash__c);
} else {
cert.AnchorStatus__c = 'Failed';
System.debug('Certyo ingestion failed: HTTP ' +
res.getStatusCode() + ' ' + res.getBody());
}
upsert cert RecordId__c;
}
/**
* Verify a record against the on-chain Merkle root.
*
* @param recordId The Certyo recordId (e.g. Order number)
* @param payloadJson The original record payload as JSON
* @return Map with verification result fields
*/
public static Map<String, Object> verifyRecord(
String recordId,
String payloadJson
) {
String tenantId = getTenantId();
Map<String, Object> body = new Map<String, Object>{
'tenantId' => tenantId,
'database' => 'salesforce',
'collection' => 'orders',
'recordId' => recordId,
'payload' => (Map<String, Object>) JSON.deserializeUntyped(payloadJson)
};
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + VERIFY_PATH);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(body));
req.setTimeout(30000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
}
return new Map<String, Object>{
'verified' => false,
'error' => 'HTTP ' + res.getStatusCode() + ': ' + res.getBody()
};
}
/**
* Query Certyo for the anchoring status of a record.
*
* @param recordId The Certyo recordId
* @return The first matching snapshot or null
*/
public static Map<String, Object> queryRecordStatus(String recordId) {
String tenantId = getTenantId();
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + QUERY_PATH +
'?tenantId=' + EncodingUtil.urlEncode(tenantId, 'UTF-8') +
'&recordId=' + EncodingUtil.urlEncode(recordId, 'UTF-8'));
req.setMethod('GET');
req.setTimeout(15000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> data =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
List<Object> items = (List<Object>) data.get('items');
if (items != null && !items.isEmpty()) {
return (Map<String, Object>) items[0];
}
}
return null;
}
}This trigger fires when an Order transitions to Activated status (Salesforce Order status indicating fulfillment). It publishes a Platform Event to decouple the callout from the DML transaction.
/**
* OrderCertyoTrigger
* Fires after update on Order. When status changes to 'Activated',
* publishes a Platform Event for async Certyo ingestion.
*/
trigger OrderCertyoTrigger on Order (after update) {
List<Certyo_Record_Ingest__e> events = new List<Certyo_Record_Ingest__e>();
for (Order newOrder : Trigger.new) {
Order oldOrder = Trigger.oldMap.get(newOrder.Id);
// Only fire when status transitions to Activated
if (newOrder.Status == 'Activated' && oldOrder.Status != 'Activated') {
// Build the payload from Order fields
Map<String, Object> payload = new Map<String, Object>{
'orderNumber' => newOrder.OrderNumber,
'accountId' => newOrder.AccountId,
'effectiveDate' => String.valueOf(newOrder.EffectiveDate),
'totalAmount' => newOrder.TotalAmount,
'status' => newOrder.Status,
'description' => newOrder.Description,
'contractId' => newOrder.ContractId,
'poNumber' => newOrder.PoNumber,
'billingCity' => newOrder.BillingCity,
'billingCountry' => newOrder.BillingCountry,
'shippingCity' => newOrder.ShippingCity,
'shippingCountry'=> newOrder.ShippingCountry
};
events.add(new Certyo_Record_Ingest__e(
Order_Id__c = newOrder.Id,
Order_Number__c = newOrder.OrderNumber,
Payload_JSON__c = JSON.serialize(payload)
));
}
}
if (!events.isEmpty()) {
List<Database.SaveResult> results = EventBus.publish(events);
for (Database.SaveResult sr : results) {
if (!sr.isSuccess()) {
for (Database.Error err : sr.getErrors()) {
System.debug('Platform Event publish failed: ' +
err.getStatusCode() + ' - ' + err.getMessage());
}
}
}
}
}Define the Platform Event via Setup > Platform Events > New Platform Event, or deploy the metadata XML below using Salesforce CLI.Certyo_Record_Ingest__e
<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
<deploymentStatus>Deployed</deploymentStatus>
<eventType>HighVolume</eventType>
<label>Certyo Record Ingest</label>
<pluralLabel>Certyo Record Ingests</pluralLabel>
<publishBehavior>PublishAfterCommit</publishBehavior>
<fields>
<fullName>Order_Id__c</fullName>
<label>Order ID</label>
<type>Text</type>
<length>18</length>
<isFilteringDisabled>false</isFilteringDisabled>
<isNameField>false</isNameField>
<isSortingDisabled>true</isSortingDisabled>
</fields>
<fields>
<fullName>Order_Number__c</fullName>
<label>Order Number</label>
<type>Text</type>
<length>80</length>
<isFilteringDisabled>false</isFilteringDisabled>
<isNameField>false</isNameField>
<isSortingDisabled>true</isSortingDisabled>
</fields>
<fields>
<fullName>Payload_JSON__c</fullName>
<label>Payload JSON</label>
<type>LongTextArea</type>
<length>131072</length>
<visibleLines>5</visibleLines>
<isFilteringDisabled>true</isFilteringDisabled>
<isNameField>false</isNameField>
<isSortingDisabled>true</isSortingDisabled>
</fields>
</CustomObject>Then create a trigger on this Platform Event that calls the : CertyoService:
/**
* CertyoRecordIngestTrigger
* Subscribes to Certyo_Record_Ingest__e Platform Events.
* Calls CertyoService.ingestRecord() via @future for each event.
*/
trigger CertyoRecordIngestTrigger on Certyo_Record_Ingest__e (after insert) {
for (Certyo_Record_Ingest__e event : Trigger.new) {
// @future(callout=true) runs asynchronously outside this transaction
CertyoService.ingestRecord(
event.Order_Id__c,
event.Order_Number__c,
event.Payload_JSON__c
);
}
}A Scheduled Apex job that runs every 15 minutes to poll Certyo for anchoring status. It queries all Certificate__c records with Pending . It queries all records with
/**
* CertyoVerificationScheduler
* Schedulable Apex that polls Certyo for anchoring status and updates
* Certificate__c records.
*
* Schedule via Anonymous Apex:
* System.schedule(
* 'Certyo Verification Poller',
* '0 0/15 * * * ?',
* new CertyoVerificationScheduler()
* );
*/
public with sharing class CertyoVerificationScheduler implements Schedulable {
public void execute(SchedulableContext ctx) {
// Delegate to Queueable so we can make callouts
System.enqueueJob(new CertyoVerificationJob());
}
}
/**
* CertyoVerificationJob
* Queueable with callout support. Processes pending certificates
* in batches to respect Salesforce governor limits.
*/
public with sharing class CertyoVerificationJob implements Queueable, Database.AllowsCallouts {
private static final Integer BATCH_SIZE = 50;
public void execute(QueueableContext ctx) {
List<Certificate__c> pending = [
SELECT Id, RecordId__c, RecordHash__c, Order__c, Order__r.OrderNumber
FROM Certificate__c
WHERE AnchorStatus__c = 'Pending'
ORDER BY CreatedDate ASC
LIMIT :BATCH_SIZE
];
if (pending.isEmpty()) {
return;
}
List<Certificate__c> toUpdate = new List<Certificate__c>();
for (Certificate__c cert : pending) {
try {
Map<String, Object> snapshot =
CertyoService.queryRecordStatus(cert.RecordId__c);
if (snapshot == null) {
continue; // Not yet processed
}
String status = (String) snapshot.get('status');
if (status == 'Anchored') {
cert.AnchorStatus__c = 'Anchored';
cert.PolygonTxHash__c = (String) snapshot.get('polygonTxHash');
cert.MerkleRoot__c = (String) snapshot.get('merkleRoot');
cert.IpfsCid__c = (String) snapshot.get('ipfsCid');
cert.SnapshotId__c = (String) snapshot.get('id');
cert.VerifiedAt__c = Datetime.now();
toUpdate.add(cert);
System.debug('Certyo: ' + cert.RecordId__c + ' anchored — ' +
cert.PolygonTxHash__c);
} else if (status == 'Failed') {
cert.AnchorStatus__c = 'Failed';
toUpdate.add(cert);
System.debug('Certyo: ' + cert.RecordId__c + ' failed anchoring');
}
// If Batched or Pending, skip — will be picked up next run
} catch (Exception e) {
System.debug('Certyo verification error for ' +
cert.RecordId__c + ': ' + e.getMessage());
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
// If we processed a full batch, chain another job for remaining records
if (pending.size() == BATCH_SIZE) {
Integer remainingCount = [
SELECT COUNT()
FROM Certificate__c
WHERE AnchorStatus__c = 'Pending'
];
if (remainingCount > 0) {
System.enqueueJob(new CertyoVerificationJob());
}
}
}
}Authentication: OAuth 2.0 client credentials
For server-to-server integrations (e.g., external middleware calling both Salesforce and Certyo), use OAuth 2.0 Client Credentials flow for Salesforce and an API key for Certyo:
# 1. Get a Salesforce access token
curl -X POST https://login.salesforce.com/services/oauth2/token \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CONNECTED_APP_CLIENT_ID" \
-d "client_secret=YOUR_CONNECTED_APP_CLIENT_SECRET"
# Response:
# {
# "access_token": "00D...",
# "instance_url": "https://yourorg.my.salesforce.com",
# "token_type": "Bearer"
# }
# 2. Query Salesforce Orders
curl https://yourorg.my.salesforce.com/services/data/v60.0/query/ \
-H "Authorization: Bearer 00D..." \
--data-urlencode "q=SELECT Id, OrderNumber, TotalAmount, Status FROM Order WHERE Status = 'Activated'"
# 3. Ingest into Certyo
curl -X POST https://www.certyos.com/api/v1/records \
-H "X-API-Key: certyo_sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "tenantId": "acme-corp", "database": "salesforce", ... }'Rate limit considerations
Salesforce imposes several governor limits that affect the integration design:
| Limit | Value | Mitigation |
|---|---|---|
| Callouts per transaction | 100 | Use @future or Queueable — one callout per async execution |
| Callout timeout | 120s (max) | Certyo returns 202 in <500ms — well within limits |
| Max @future calls per transaction | 50 | Batch events and use Queueable chaining for >50 records |
| Platform Event publish limit | 10,000/hour (High Volume) | Sufficient for most use cases; use Change Data Capture for higher volume |
| Async Apex daily limit | 250,000 or # licenses x 200 | Monitor via Setup > Apex Jobs; use bulk patterns |
Custom Setting: Certyo_Settings__c
Create a Hierarchy Custom Setting via Setup > Custom Settings > New to store the tenant ID and other configuration values that the reads at runtime: CertyoService
Tenant_Id__c— Text(255). Your Certyo tenant identifier.Database_Name__c— Text(255). Defaults to .salesforce.Verification_Enabled__c— Checkbox. Toggle the scheduled verification job.
Set org-wide defaults via Setup > Custom Settings > Certyo_Settings__c > Manage > New (Org Default).
Deployment checklist
- Create the custom object with all fields listed above
Certificate__c - Create the Hierarchy Custom Setting and set org defaults
Certyo_Settings__c - Create the Named Credential with your production API key
Certyo_API - Define the Platform Event
Certyo_Record_Ingest__e - Deploy
CertyoService.cls,OrderCertyoTrigger.trigger, andCertyoRecordIngestTrigger.trigger - Deploy
CertyoVerificationScheduler.clsandCertyoVerificationJob.cls - Schedule the verification job:
System.schedule('Certyo Verification', '0 0/15 * * * ?', new CertyoVerificationScheduler()) - Add the related list to the Order page layout
Certificate__c - Test end-to-end: activate an Order and verify the Certificate__c record transitions from Pending to Anchored within 2 minutes
AI Integration Skill
Download a skill file that enables AI agents to generate working Salesforce + Certyo integration code for any language or framework.
What's inside
- Authentication — Named Credentials for API key storage and OAuth 2.0 client credentials
- Architecture — Order trigger → Platform Event → @future callout → Certificate__c update
- Field mapping — Order, Product, and Fulfillment fields to Certyo record schema
- Code examples — CertyoService Apex class, Apex trigger, Platform Event, Scheduled verification
- Governor limits — 100 callouts/transaction, @future and Queueable patterns
- Custom objects — Certificate__c definition with RecordHash, AnchorStatus, PolygonTxHash fields
How to use
Claude Code
Place the file in your project's .claude/commands/ directory, then use it as a slash command:
# Download the skill file
mkdir -p .claude/commands
curl -o .claude/commands/certyo-salesforce.md \
https://www.certyos.com/developers/skills/certyo-salesforce-skill.md
# Use it in Claude Code
/certyo-salesforce "Generate an Apex service that ingests Order records into Certyo"Cursor / Copilot / Any AI Agent
Add the file to your project root or attach it to a conversation. The AI agent will use the Salesforce-specific patterns, field mappings, and code examples to generate correct integration code.
# Add to your project
curl -o CERTYO_SALESFORCE.md \
https://www.certyos.com/developers/skills/certyo-salesforce-skill.md
# Then in your AI agent:
"Using the Certyo Salesforce spec in CERTYO_SALESFORCE.md,
generate an apex service that ingests order records into certyo"CLAUDE.md Context File
Append the skill file to your project's CLAUDE.md so every Claude conversation has Salesforce + Certyo context automatically.
# Append to your project's CLAUDE.md
echo "" >> CLAUDE.md
echo "## Certyo Salesforce Integration" >> CLAUDE.md
cat CERTYO_SALESFORCE.md >> CLAUDE.md