Sender Profiles: Multi-Tenant Resource Governance
Sender Profiles provide an architectural abstraction that enables true multi-tenancy within a single Sent organization. Rather than creating separate accounts for each business unit, brand, or customer, Sender Profiles allow you to isolate messaging identities while selectively sharing resources across organizational boundaries.
The Sender Profile Abstraction
Traditional approaches to multi-tenant messaging force a binary choice: completely separate accounts with full isolation (and operational overhead), or a shared account with no isolation (and security risks). Sender Profiles introduce a governed resource model that allows selective isolation and sharing on a per-resource basis.
The Inheritance Architecture
Sender Profiles implement a hierarchical resource inheritance model:
Organization Level
The root entity that owns billing, compliance, and shared resources. Organizations serve as the administrative boundary and cost center.
Sender Profile Level
Individual messaging identities that can inherit resources from the organization or maintain dedicated resources. Each profile has its own API credentials, sending identity, and configuration.
Resource Level
Individual resources (billing, WhatsApp Business Account, contacts, templates, TCR) can be inherited, shared, or dedicated per profile.
This model enables use cases like agencies managing multiple client brands, franchises with centralized billing but local operations, or platforms offering white-labeled messaging.
Resource Governance Model
Each resource type implements a specific governance pattern:
| Resource | Inheritance Pattern | Use Case |
|---|---|---|
| Billing | Organization or Dedicated | Centralized cost control vs. separate invoicing |
| WhatsApp Business Account | Organization, Profile, or Dedicated | Shared WhatsApp number vs. brand-specific presence |
| Contacts | Inherit + Share toggle | Shared customer database vs. isolated lists |
| Templates | Inherit + Share toggle | Global template library vs. brand-specific content |
| TCR Brand/Campaign | Full/Partial/Dedicated | Unified compliance vs. separate brand registration |
Converting to an Organization
Sender Profiles require an organization structure. Converting a personal account creates the root entity and establishes the first sender profile:
Navigate to Profiles Page
Navigate to the Profiles page in the dashboard and click on the + Create Sender Profile button.
Create Initial Sender Profile
The first sender profile inherits from the organization by default and becomes your primary messaging identity.
Configure Resource Inheritance
For each resource, decide whether to inherit from the organization or maintain dedicated configuration.
📚 See Resource Configuration Patterns below for a detailed explanation of the different resource configuration patterns.
Sender Profile Created
The sender profile is created and you are redirected to the Sender Profiles page.
Resource Configuration Patterns
Billing Inheritance
Billing governance offers two modes:
- Inherited: All charges flow to the organization's payment methods. Ideal for centralized cost management and single-invoice accounting.
- Dedicated: Profile-specific billing with separate payment methods. Useful when clients or business units need direct billing.
Contacts and Templates
Contacts and templates implement a dual-mode sharing model:
- Inheritance: Access resources from the organization level
- Sharing: Allow this profile's resources to be accessed by other organization members
When sharing is enabled, any profile inheriting from the organization can access those resources. Consider data privacy implications when sharing contact lists across business units.
TCR Brand and Campaign
For US SMS compliance, TCR registration offers flexible inheritance:
- Fully Inherited: Use organization's brand and campaign
- Partially Inherited: Inherit brand but create dedicated campaign, or vice versa
- Dedicated: Complete separate TCR registration for this profile
Managing Multiple Profiles
Profile Switching
The dashboard provides a profile selector to switch between sender profiles:
Profile Listing
View and manage all profiles within your organization:
Sender Profiles for Developers
API Credentials and Isolation
Each sender profile has unique API credentials. The API key determines which profile's resources are used:
curl -X POST "https://api.sent.dm/v3/messages" \
-H "x-api-key: $PROFILE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["+1234567890"],
"template": {"id": "tmpl_123"}
}'import SentDm from '@sentdm/sentdm';
// Uses profile-specific API key
const client = new SentDm({ apiKey: process.env.PROFILE_API_KEY });
await client.messages.send({
to: ['+1234567890'],
template: { id: 'tmpl_123' }
});from sent_dm import SentDm
client = SentDm(api_key=os.environ['PROFILE_API_KEY'])
client.messages.send(
to=["+1234567890"],
template={"id": "tmpl_123"}
)package main
import (
"context"
"os"
"github.com/sentdm/sent-dm-go"
"github.com/sentdm/sent-dm-go/option"
)
func main() {
client := sentdm.NewClient(
option.WithAPIKey(os.Getenv("PROFILE_API_KEY")),
)
client.Messages.Send(context.Background(), sentdm.MessageSendParams{
To: []string{"+1234567890"},
Template: sentdm.MessageSendParamsTemplate{
ID: sentdm.String("tmpl_123"),
},
})
}SentDmClient client = SentDmOkHttpClient.builder()
.apiKey(System.getenv("PROFILE_API_KEY"))
.build();
client.messages().send(MessageSendParams.builder()
.addTo("+1234567890")
.template(MessageSendParams.Template.builder()
.id("tmpl_123")
.build())
.build());SentDmClient client = new(apiKey: Environment.GetEnvironmentVariable("PROFILE_API_KEY"));
await client.Messages.SendAsync(new MessageSendParams
{
To = new List<string> { "+1234567890" },
Template = new MessageSendParamsTemplate { Id = "tmpl_123" }
});use SentDM\Client;
$client = new Client($_ENV['PROFILE_API_KEY']);
$client->messages->send(
to: ['+1234567890'],
template: ['id' => 'tmpl_123']
);require "sentdm"
client = Sentdm::Client.new(api_key: ENV["PROFILE_API_KEY"])
client.messages.send(
to: ["+1234567890"],
template: { id: "tmpl_123" }
)Messages sent using a profile's API key automatically use that profile's configured resources—WhatsApp Business Account, templates, contacts, and TCR settings.
Multi-Tenant Application Pattern
For platforms serving multiple customers, map each customer to a sender profile:
async function sendOnBehalfOfCustomer(customerId: string, message: MessageRequest) {
const profile = await db.senderProfiles.findByCustomer(customerId);
const client = new SentDm({ apiKey: profile.apiKey });
return client.messages.send({
to: message.recipients,
template: { id: message.templateId },
variables: message.variables
});
}def send_on_behalf_of_customer(customer_id: str, message: dict):
profile = db.sender_profiles.find_by_customer(customer_id)
client = SentDm(api_key=profile.api_key)
return client.messages.send(
to=message["recipients"],
template={"id": message["template_id"]},
variables=message["variables"]
)func sendOnBehalfOfCustomer(customerID string, msg MessageRequest) (*sentdm.MessageResponse, error) {
profile, _ := db.SenderProfiles.FindByCustomer(customerID)
client := sentdm.NewClient(option.WithAPIKey(profile.APIKey))
return client.Messages.Send(context.Background(), sentdm.MessageSendParams{
To: msg.Recipients,
Template: sentdm.MessageSendParamsTemplate{ID: &msg.TemplateID},
})
}public MessageResponse sendOnBehalfOfCustomer(String customerId, MessageRequest message) {
SenderProfile profile = db.senderProfiles().findByCustomer(customerId);
SentDmClient client = SentDmOkHttpClient.builder()
.apiKey(profile.getApiKey())
.build();
return client.messages().send(MessageSendParams.builder()
.addTo(message.getRecipients().get(0))
.template(MessageSendParams.Template.builder()
.id(message.getTemplateId())
.build())
.build());
}public async Task<MessageResponse> SendOnBehalfOfCustomer(string customerId, MessageRequest message)
{
var profile = await db.SenderProfiles.FindByCustomerAsync(customerId);
var client = new SentDmClient(apiKey: profile.ApiKey);
return await client.Messages.SendAsync(new MessageSendParams
{
To = message.Recipients,
Template = new MessageSendParamsTemplate { Id = message.TemplateId },
Variables = message.Variables
});
}function sendOnBehalfOfCustomer(string $customerId, array $message): array
{
$profile = $this->db->senderProfiles->findByCustomer($customerId);
$client = new Client($profile->apiKey);
return $client->messages->send(
to: $message['recipients'],
template: ['id' => $message['template_id']],
variables: $message['variables'] ?? []
);
}def send_on_behalf_of_customer(customer_id, message)
profile = db.sender_profiles.find_by_customer(customer_id)
client = Sentdm::Client.new(api_key: profile.api_key)
client.messages.send(
to: message[:recipients],
template: { id: message[:template_id] },
variables: message[:variables]
)
endWebhook Handling with Profiles
Track usage per sender profile by looking up the message context from your database when webhooks arrive:
app.post('/webhooks/sent', async (req, res) => {
res.sendStatus(200);
const { field, payload } = req.body;
if (field === 'messages' && payload.message_status === 'SENT') {
// Retrieve sender profile context from your database
const message = await db.messages.findBySentId(payload.message_id);
if (message?.senderProfileId) {
await analytics.track('message_sent', {
senderProfileId: message.senderProfileId,
messageId: payload.message_id,
channel: payload.channel,
inboundNumber: payload.inbound_number,
timestamp: new Date()
});
}
}
});@app.post("/webhooks/sent")
async def webhook(request: Request):
data = await request.json()
p = data.get("payload", {})
if data.get("field") == "messages" and p.get("message_status") == "SENT":
# Retrieve sender profile context from your database
message = db.messages.find_by_sent_id(p["message_id"])
if message and message.sender_profile_id:
analytics.track("message_sent", {
"sender_profile_id": message.sender_profile_id,
"message_id": p["message_id"],
"channel": p["channel"],
"inbound_number": p["inbound_number"],
"timestamp": datetime.now().isoformat()
})
return {"status": "ok"}func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event struct {
Field string `json:"field"`
Payload struct {
MessageID string `json:"message_id"`
MessageStatus string `json:"message_status"`
Channel string `json:"channel"`
InboundNumber string `json:"inbound_number"`
} `json:"payload"`
}
json.NewDecoder(r.Body).Decode(&event)
w.WriteHeader(http.StatusOK)
if event.Field == "messages" && event.Payload.MessageStatus == "SENT" {
// Retrieve sender profile context from your database
message, _ := db.Messages.FindBySentID(event.Payload.MessageID)
if message != nil && message.SenderProfileID != "" {
analytics.Track("message_sent", map[string]interface{}{
"sender_profile_id": message.SenderProfileID,
"message_id": event.Payload.MessageID,
"channel": event.Payload.Channel,
"inbound_number": event.Payload.InboundNumber,
})
}
}
}@PostMapping("/webhooks/sent")
public ResponseEntity<Void> webhook(@RequestBody WebhookEvent event) {
if ("messages".equals(event.getField()) && "SENT".equals(event.getPayload().getMessageStatus())) {
// Retrieve sender profile context from your database
MessageRecord message = db.messages().findBySentId(event.getPayload().getMessageId());
if (message != null && message.getSenderProfileId() != null) {
analytics.track("message_sent", Map.of(
"sender_profile_id", message.getSenderProfileId(),
"message_id", event.getPayload().getMessageId(),
"channel", event.getPayload().getChannel(),
"inbound_number", event.getPayload().getInboundNumber()
));
}
}
return ResponseEntity.ok().build();
}[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
[HttpPost("sent")]
public async Task<IActionResult> Webhook([FromBody] WebhookEvent evt)
{
if (evt.Field == "messages" && evt.Payload.MessageStatus == "SENT")
{
// Retrieve sender profile context from your database
var message = await db.Messages.FindBySentIdAsync(evt.Payload.MessageId);
if (message?.SenderProfileId != null)
{
await analytics.TrackAsync("message_sent", new {
sender_profile_id = message.SenderProfileId,
message_id = evt.Payload.MessageId,
channel = evt.Payload.Channel,
inbound_number = evt.Payload.InboundNumber
});
}
}
return Ok();
}
}#[Post('/webhooks/sent')]
public function webhook(Request $request): JsonResponse
{
$data = $request->getPayload()->all();
$p = $data['payload'] ?? [];
if (($data['field'] ?? '') === 'messages' && ($p['message_status'] ?? '') === 'SENT') {
// Retrieve sender profile context from your database
$message = $this->db->messages->findBySentId($p['message_id']);
if ($message && $message->senderProfileId) {
$this->analytics->track('message_sent', [
'sender_profile_id' => $message->senderProfileId,
'message_id' => $p['message_id'],
'channel' => $p['channel'],
'inbound_number' => $p['inbound_number'],
]);
}
}
return new JsonResponse(['status' => 'ok']);
}post '/webhooks/sent' do
request.body.rewind
data = JSON.parse(request.body.read)
p = data['payload'] || {}
if data['field'] == 'messages' && p['message_status'] == 'SENT'
# Retrieve sender profile context from your database
message = db.messages.find_by_sent_id(p['message_id'])
if message && message.sender_profile_id
analytics.track('message_sent', {
sender_profile_id: message.sender_profile_id,
message_id: p['message_id'],
channel: p['channel'],
inbound_number: p['inbound_number']
})
end
end
{ status: 'ok' }.to_json
endArchitectural Patterns
Pattern 1: Agency Model
Centralized billing with dedicated WhatsApp Business Account per client:
- Billing: Inherited (agency pays)
- WhatsApp Business Account: Dedicated per profile (client branding)
- Contacts: Dedicated (client data isolation)
- Templates: Inherited + Shared (agency provides templates)
Pattern 2: Franchise Model
Shared resources with local brand presence:
- Billing: Inherited (franchisor manages)
- WhatsApp Business Account: Dedicated per location (local phone numbers)
- Contacts: Inherited (shared customer base)
- Templates: Inherited (franchise-wide messaging)
Pattern 3: Platform/ISV Model
Full tenant isolation:
- Billing: Dedicated (tenants pay directly)
- WhatsApp Business Account: Dedicated per tenant
- Contacts: Dedicated
- Templates: Dedicated
Best Practices
Security Considerations
- Store profile API keys in separate secrets management systems for true isolation
- Use webhook signature verification to ensure events originate from the expected profile
- Implement rate limiting per profile to prevent one tenant from consuming quota
Cost Management
- Use inherited billing for centralized cost control
- Use dedicated billing when clients need direct invoicing
- Monitor usage per profile through webhook events
Compliance
- Use dedicated TCR registration when brands require separate identity
- Use inherited contacts only when data sharing agreements permit
- Document resource sharing policies for your organization