---
name: Certyo + Salesforce Integration
version: 1.0.0
description: Generate Salesforce → Certyo integration code with field mappings, auth, and working examples
api_base: https://www.certyos.com
auth: X-API-Key header
last_updated: 2026-04-14
---

# Certyo + Salesforce Integration Skill

This skill generates production-ready Apex code to integrate Salesforce with Certyo's blockchain-backed authenticity platform. It covers record ingestion, verification polling, and certificate write-back using Salesforce best practices (Named Credentials, Platform Events, governor-limit-safe async patterns).

## Certyo API Reference

All requests require the `X-API-Key` header for authentication.

### Endpoints

| Method | Path | Description | Response |
|--------|------|-------------|----------|
| `POST` | `/api/v1/records` | Ingest a single record | `202 Accepted` |
| `POST` | `/api/v1/records/bulk` | Ingest up to 1000 records | `202 Accepted` |
| `POST` | `/api/v1/verify/record` | Verify blockchain anchoring | `200 OK` |
| `GET` | `/api/v1/records` | Query records | `200 OK` |

### Record Payload

```json
{
  "tenantId": "string (required)",
  "database": "string (required)",
  "collection": "string (required)",
  "recordId": "string (required)",
  "recordPayload": { "...any JSON (required)" },
  "clientId": "string (optional)",
  "recordVersion": "string (optional, default '1')",
  "operationType": "upsert|insert|update|delete (optional)",
  "sourceTimestamp": "ISO 8601 (optional)",
  "idempotencyKey": "string (optional)"
}
```

### Ingestion Response (202 Accepted)

```json
{
  "recordId": "ORD-00001234",
  "recordHash": "sha256:ab12cd34...",
  "tenantId": "acme-corp",
  "acceptedAt": "2026-04-14T10:30:00Z",
  "idempotencyReplayed": false
}
```

### Pipeline Timing

Records flow through: Kafka -> Accumulate (1000 records or 60s) -> Merkle tree -> IPFS pin -> Polygon anchor. Total anchoring latency is approximately 60-90 seconds after accumulation flush.

## Integration Pattern

```
Order status change
  -> Apex Trigger (before/after update)
    -> Publishes Certyo_Record_Event__e (Platform Event)
      -> CertyoEventTrigger subscribes
        -> @future(callout=true) CertyoService.ingestRecord()
          -> POST /api/v1/records (Named Credential)
            -> Creates/updates Certificate__c record
              -> Scheduled Apex polls POST /api/v1/verify/record every 5 min
                -> Updates Certificate__c.AnchorStatus__c = 'Anchored'
```

This pattern ensures zero callouts in synchronous trigger context, governor-limit compliance, and reliable async processing via Platform Events.

## Authentication

### Named Credential Setup

Create a Named Credential in Salesforce Setup to store the Certyo API key securely.

**Named Credential configuration:**

| Field | Value |
|-------|-------|
| Label | Certyo API |
| Name | Certyo_API |
| URL | `https://www.certyos.com` |
| Identity Type | Named Principal |
| Authentication Protocol | No Authentication |
| Generate Authorization Header | Unchecked |

The API key is passed via a custom header. Store it in a Custom Metadata Type or Protected Custom Setting and attach it in the Apex callout. This avoids hardcoding secrets.

### Custom Metadata for API Key

```xml
<!-- Certyo_Setting__mdt -->
<CustomMetadata>
    <label>Default</label>
    <values>
        <field>API_Key__c</field>
        <value>your-api-key-here</value>
    </values>
    <values>
        <field>Tenant_Id__c</field>
        <value>acme-corp</value>
    </values>
</CustomMetadata>
```

## Field Mapping

| Salesforce Field | Certyo Field | Notes |
|-----------------|-------------|-------|
| `Order.OrderNumber` | `recordId` | Unique identifier for the record |
| `Order.CloseDate` | `sourceTimestamp` | ISO 8601 format via `Datetime.newInstance()` |
| Hardcoded `"salesforce"` | `database` | Identifies source system |
| Hardcoded `"orders"` | `collection` | Identifies object type |
| `UserInfo.getOrganizationId()` | `clientId` | Links to the Salesforce org |
| `"upsert"` | `operationType` | Default for trigger-based ingestion |
| `Order.Id + '-' + Order.LastModifiedDate.getTime()` | `idempotencyKey` | Prevents duplicate ingestion on retries |
| Full order JSON | `recordPayload` | Serialized order with line items |

## Code Examples

### 1. Certificate__c Custom Object

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
    <label>Certificate</label>
    <pluralLabel>Certificates</pluralLabel>
    <nameField>
        <label>Certificate Name</label>
        <type>AutoNumber</type>
        <displayFormat>CERT-{0000000}</displayFormat>
    </nameField>
    <fields>
        <fullName>Order__c</fullName>
        <label>Order</label>
        <type>Lookup</type>
        <referenceTo>Order</referenceTo>
        <relationshipName>Certificates</relationshipName>
    </fields>
    <fields>
        <fullName>RecordId__c</fullName>
        <label>Certyo Record ID</label>
        <type>Text</type>
        <length>255</length>
        <externalId>true</externalId>
        <unique>true</unique>
    </fields>
    <fields>
        <fullName>RecordHash__c</fullName>
        <label>Record Hash (SHA-256)</label>
        <type>Text</type>
        <length>255</length>
    </fields>
    <fields>
        <fullName>AnchorStatus__c</fullName>
        <label>Anchor Status</label>
        <type>Picklist</type>
        <valueSet>
            <restricted>true</restricted>
            <valueSetDefinition>
                <value><fullName>Pending</fullName><default>true</default></value>
                <value><fullName>Anchored</fullName></value>
                <value><fullName>Failed</fullName></value>
            </valueSetDefinition>
        </valueSet>
    </fields>
    <fields>
        <fullName>PolygonTxHash__c</fullName>
        <label>Polygon Transaction Hash</label>
        <type>Text</type>
        <length>255</length>
    </fields>
    <fields>
        <fullName>VerifiedAt__c</fullName>
        <label>Verified At</label>
        <type>DateTime</type>
    </fields>
</CustomObject>
```

### 2. Platform Event Definition

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
    <label>Certyo Record Event</label>
    <pluralLabel>Certyo Record Events</pluralLabel>
    <eventType>HighVolume</eventType>
    <fields>
        <fullName>Order_Id__c</fullName>
        <label>Order Id</label>
        <type>Text</type>
        <length>18</length>
    </fields>
    <fields>
        <fullName>Order_Number__c</fullName>
        <label>Order Number</label>
        <type>Text</type>
        <length>255</length>
    </fields>
    <fields>
        <fullName>Order_Payload__c</fullName>
        <label>Order Payload JSON</label>
        <type>LongTextArea</type>
        <length>131072</length>
        <visibleLines>5</visibleLines>
    </fields>
    <fields>
        <fullName>Source_Timestamp__c</fullName>
        <label>Source Timestamp</label>
        <type>DateTime</type>
    </fields>
</CustomObject>
```

### 3. CertyoService Apex Class

```apex
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';

    // Retrieve config from Custom Metadata
    private static Certyo_Setting__mdt getSettings() {
        return Certyo_Setting__mdt.getInstance('Default');
    }

    // --- Ingest a single record (called via @future) ---
    @future(callout=true)
    public static void ingestRecord(String orderId, String orderNumber,
                                     String payloadJson, String sourceTimestamp) {
        Certyo_Setting__mdt settings = getSettings();

        Map<String, Object> body = new Map<String, Object>{
            'tenantId'       => settings.Tenant_Id__c,
            'database'       => 'salesforce',
            'collection'     => 'orders',
            'recordId'       => orderNumber,
            'recordPayload'  => (Map<String, Object>) JSON.deserializeUntyped(payloadJson),
            'clientId'       => UserInfo.getOrganizationId(),
            'operationType'  => 'upsert',
            'sourceTimestamp' => sourceTimestamp,
            'idempotencyKey' => orderId + '-' + String.valueOf(Datetime.now().getTime())
        };

        HttpRequest req = new HttpRequest();
        req.setEndpoint(NAMED_CREDENTIAL + INGEST_PATH);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('X-API-Key', settings.API_Key__c);
        req.setBody(JSON.serialize(body));
        req.setTimeout(30000);

        HttpResponse res = new Http().send(req);

        if (res.getStatusCode() == 202) {
            Map<String, Object> result = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
            String recordHash = (String) result.get('recordHash');
            String certRecordId = (String) result.get('recordId');

            Certificate__c cert = new Certificate__c(
                RecordId__c     = certRecordId,
                RecordHash__c   = recordHash,
                AnchorStatus__c = 'Pending',
                Order__c        = orderId
            );

            // Upsert by external ID to handle retries gracefully
            Database.upsert(cert, Certificate__c.RecordId__c, false);
        } else {
            System.debug(LoggingLevel.ERROR,
                'Certyo ingestion failed: ' + res.getStatusCode() + ' ' + res.getBody());
            // Create a failed certificate for visibility
            Certificate__c cert = new Certificate__c(
                RecordId__c     = orderNumber,
                AnchorStatus__c = 'Failed',
                Order__c        = orderId
            );
            Database.upsert(cert, Certificate__c.RecordId__c, false);
        }
    }

    // --- Verify a record's blockchain anchoring ---
    @future(callout=true)
    public static void verifyRecord(String certificateId, String recordId,
                                     String recordHash, String tenantId) {
        Certyo_Setting__mdt settings = getSettings();

        Map<String, Object> body = new Map<String, Object>{
            'tenantId'   => tenantId,
            'recordId'   => recordId,
            'recordHash' => recordHash
        };

        HttpRequest req = new HttpRequest();
        req.setEndpoint(NAMED_CREDENTIAL + VERIFY_PATH);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('X-API-Key', settings.API_Key__c);
        req.setBody(JSON.serialize(body));
        req.setTimeout(30000);

        HttpResponse res = new Http().send(req);

        if (res.getStatusCode() == 200) {
            Map<String, Object> result = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
            Boolean verified = (Boolean) result.get('verified');
            String txHash = (String) result.get('polygonTxHash');

            Certificate__c cert = new Certificate__c(
                Id              = certificateId,
                AnchorStatus__c = verified ? 'Anchored' : 'Pending',
                PolygonTxHash__c = txHash,
                VerifiedAt__c   = verified ? Datetime.now() : null
            );
            update cert;
        }
    }

    // --- Build order payload JSON for ingestion ---
    public static String buildOrderPayload(Order ord, List<OrderItem> items) {
        Map<String, Object> payload = new Map<String, Object>{
            'orderId'       => ord.Id,
            'orderNumber'   => ord.OrderNumber,
            'status'        => ord.Status,
            'totalAmount'   => ord.TotalAmount,
            'accountId'     => ord.AccountId,
            'accountName'   => ord.Account?.Name,
            'effectiveDate' => String.valueOf(ord.EffectiveDate),
            'lineItems'     => new List<Map<String, Object>>()
        };

        List<Map<String, Object>> lineItemsList = new List<Map<String, Object>>();
        for (OrderItem item : items) {
            lineItemsList.add(new Map<String, Object>{
                'product'    => item.Product2?.Name,
                'quantity'   => item.Quantity,
                'unitPrice'  => item.UnitPrice,
                'totalPrice' => item.TotalPrice
            });
        }
        payload.put('lineItems', lineItemsList);

        return JSON.serialize(payload);
    }
}
```

### 4. Apex Trigger on Order (Publishes Platform Event)

```apex
trigger OrderCertyoTrigger on Order (after update) {
    List<Certyo_Record_Event__e> events = new List<Certyo_Record_Event__e>();

    for (Order ord : Trigger.new) {
        Order oldOrd = Trigger.oldMap.get(ord.Id);

        // Fire only when Status changes to a target value
        if (ord.Status != oldOrd.Status &&
            (ord.Status == 'Activated' || ord.Status == 'Fulfilled')) {

            // Query line items for the payload (outside trigger best practice:
            // use a helper, but shown inline for clarity)
            events.add(new Certyo_Record_Event__e(
                Order_Id__c        = ord.Id,
                Order_Number__c    = ord.OrderNumber,
                Order_Payload__c   = '', // Populated by event trigger handler
                Source_Timestamp__c = Datetime.now()
            ));
        }
    }

    if (!events.isEmpty()) {
        List<Database.SaveResult> results = EventBus.publish(events);
        for (Database.SaveResult sr : results) {
            if (!sr.isSuccess()) {
                System.debug(LoggingLevel.ERROR,
                    'Failed to publish Certyo event: ' + sr.getErrors());
            }
        }
    }
}
```

### 5. Platform Event Trigger (Subscribes and Calls @future)

```apex
trigger CertyoEventTrigger on Certyo_Record_Event__e (after insert) {
    // Collect order IDs to query full data
    Set<Id> orderIds = new Set<Id>();
    for (Certyo_Record_Event__e evt : Trigger.new) {
        orderIds.add(evt.Order_Id__c);
    }

    // Query orders with line items
    Map<Id, Order> orders = new Map<Id, Order>([
        SELECT Id, OrderNumber, Status, TotalAmount, AccountId,
               Account.Name, EffectiveDate,
               (SELECT Product2.Name, Quantity, UnitPrice, TotalPrice
                FROM OrderItems)
        FROM Order
        WHERE Id IN :orderIds
    ]);

    for (Certyo_Record_Event__e evt : Trigger.new) {
        Order ord = orders.get(evt.Order_Id__c);
        if (ord != null) {
            String payload = CertyoService.buildOrderPayload(ord, ord.OrderItems);
            String sourceTs = evt.Source_Timestamp__c != null
                ? evt.Source_Timestamp__c.formatGMT('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'')
                : Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'');

            CertyoService.ingestRecord(
                ord.Id,
                ord.OrderNumber,
                payload,
                sourceTs
            );
        }
    }
}
```

### 6. Scheduled Apex for Verification Polling

```apex
public with sharing class CertyoVerificationScheduler implements Schedulable {

    // Schedule expression: every 5 minutes
    // System.schedule('Certyo Verify', '0 0/5 * * * ?', new CertyoVerificationScheduler());

    public void execute(SchedulableContext sc) {
        // Query pending certificates older than 2 minutes (pipeline needs ~60-90s)
        List<Certificate__c> pendingCerts = [
            SELECT Id, RecordId__c, RecordHash__c, Order__r.Account.Name
            FROM Certificate__c
            WHERE AnchorStatus__c = 'Pending'
            AND CreatedDate < :Datetime.now().addMinutes(-2)
            ORDER BY CreatedDate ASC
            LIMIT 50
        ];

        Certyo_Setting__mdt settings = Certyo_Setting__mdt.getInstance('Default');

        // Each @future call counts as one callout; respect governor limits
        for (Certificate__c cert : pendingCerts) {
            try {
                CertyoService.verifyRecord(
                    cert.Id,
                    cert.RecordId__c,
                    cert.RecordHash__c,
                    settings.Tenant_Id__c
                );
            } catch (Exception e) {
                System.debug(LoggingLevel.ERROR,
                    'Verification scheduling failed for ' + cert.Id + ': ' + e.getMessage());
            }
        }
    }
}
```

## Verification & Write-back

After Certyo anchors a record on Polygon (typically 60-90 seconds after accumulation flush), the verification polling job confirms anchoring status.

### Verification Flow

1. `CertyoVerificationScheduler` runs every 5 minutes
2. Queries `Certificate__c` records where `AnchorStatus__c = 'Pending'` and `CreatedDate` is older than 2 minutes
3. Calls `CertyoService.verifyRecord()` via `@future(callout=true)` for each pending certificate
4. `verifyRecord` calls `POST /api/v1/verify/record` with the `recordId`, `recordHash`, and `tenantId`
5. On success, updates `Certificate__c`:
   - `AnchorStatus__c` = `'Anchored'`
   - `PolygonTxHash__c` = transaction hash from response
   - `VerifiedAt__c` = current timestamp

### Write-back DML

```apex
Certificate__c cert = new Certificate__c(
    Id               = certificateId,
    AnchorStatus__c  = 'Anchored',
    PolygonTxHash__c = 'sha256:abc123...',
    VerifiedAt__c    = Datetime.now()
);
update cert;
```

### Displaying Certificate Status on Order Page

Add a Lightning Web Component or related list on the Order record page to show the `Certificate__c` records. Users can see anchoring status at a glance.

## Code Generation Rules

1. **Never make callouts in synchronous trigger context.** Always use `@future(callout=true)`, Queueable Apex, or Platform Events to move callout logic to an async execution context. Synchronous callouts in triggers cause runtime exceptions.

2. **Always use Named Credentials for endpoint URLs.** Never hardcode `https://www.certyos.com` in Apex code. Use `callout:Certyo_API` so that the endpoint URL is managed in Salesforce Setup and can be changed without code deployment.

3. **Store API keys in Custom Metadata Types or Protected Custom Settings.** Never hardcode the `X-API-Key` value in Apex classes. Use `Certyo_Setting__mdt` (Custom Metadata Type) which is deployable and accessible without SOQL query limits.

4. **Use `@future(callout=true)` or Queueable for single-record operations.** For batch operations exceeding 50 records, use Batch Apex (`Database.Batchable`) with `Database.AllowsCallouts`. Each batch execution gets its own governor limits.

5. **Respect Salesforce governor limits at all times.** Key limits: 100 callouts per transaction, 120-second total callout timeout, 50 `@future` calls per transaction, 6 MB heap size. Design batching logic to stay within these boundaries.

6. **Use Platform Events for decoupling trigger logic from callout logic.** Platform Events provide reliable async delivery, survive transaction rollbacks (after publish), and enable retry patterns. Use High Volume event type for production workloads.

7. **Implement idempotency keys on every ingestion call.** Construct keys from `Order.Id + timestamp` to prevent duplicate records when Salesforce retries `@future` methods after transient failures.

8. **Use `Database.upsert` with external ID fields for write-back.** Upsert on `Certificate__c.RecordId__c` handles both insert (first ingestion) and update (retry or re-ingestion) without requiring pre-query logic.

9. **Poll for verification, do not rely on webhooks.** Salesforce cannot natively receive inbound webhooks without a Site or Connected App. Use Scheduled Apex running every 5 minutes to check anchoring status via `POST /api/v1/verify/record`.

10. **Log failures to `Certificate__c` with `AnchorStatus__c = 'Failed'`.** Never silently swallow errors. Create or update the Certificate record with a Failed status so operations teams have visibility. Use `System.debug(LoggingLevel.ERROR, ...)` for platform logs.
